from flask import Flask, request, jsonify
import os, json, re as _re, zipfile as _zf
from io import BytesIO

app = Flask(__name__)


# ══════════════════════════════════════════════════════════════════
#  FLOW CONFIG — Credentials & Connection Parameters
# ══════════════════════════════════════════════════════════════════

os.environ.setdefault("TARDIS_TEST_ECKARD",
    "openmdm.ECKARD.qIdbZM65qp0SPFNYk4WiGwXu9ZtfTab0asS0KUCWFenYr1Om9oyRZkBmNVQjKZ9phBHqDvot6jbNhVNrw")

FLOW_CONFIG = {
    # TARDIS / OpenMDM
    "TARDIS_USER":     "yj13214",
    "TARDIS_HOST":     "test",
    "TARDIS_TOKEN":    os.environ.get("TARDIS_TEST_ECKARD", ""),
    "TARDIS_SOURCE":   "TEST_ENG_CIV",
    "DEFAULT_PROJECT": "CFM56.CFM56-7B",
    "DEFAULT_POOL":    "engine",
    # HYPERBOOST
    "HB_BASE":         "http://53.129.113.112:9005/api/v1/tms",
    "HB_USER":         "pst4",
    "HB_PASSWORD":     "pst4",
    "HB_TESTBED":      "MTU-L-4",
}

# ── Settings-Merge: settings.json überschreibt Hardcoded-Defaults ─
_SETTINGS_DIR  = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'settings')
os.makedirs(_SETTINGS_DIR, exist_ok=True)
_SETTINGS_FILE    = os.path.join(_SETTINGS_DIR, 'settings.json')
_CIPA_CONFIG_FILE = os.path.join(_SETTINGS_DIR, 'cipa_config.json')

def _load_settings():
    if os.path.exists(_SETTINGS_FILE):
        with open(_SETTINGS_FILE) as f:
            return json.load(f)
    return {}

def _save_settings(data):
    with open(_SETTINGS_FILE, 'w') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)

def _load_cipa_config():
    if os.path.exists(_CIPA_CONFIG_FILE):
        with open(_CIPA_CONFIG_FILE) as f:
            return json.load(f)
    return {"mapping": [], "pin_config": {"trim_levels": {}, "engine_configurations": {}, "pmux_options": {}}}

def _save_cipa_config(data):
    with open(_CIPA_CONFIG_FILE, 'w') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)

_persisted_settings = _load_settings()
for _k, _v in _persisted_settings.get('connections', {}).items():
    if _k in FLOW_CONFIG and _v:
        FLOW_CONFIG[_k] = _v


# ══════════════════════════════════════════════════════════════════
#  SAP CSV — Kanban Shop-Spalte
# ══════════════════════════════════════════════════════════════════

SAP_DATA_PATH         = r'L:\lgroup\@IT_Data\PPS\ZLSGS_Dataexport.csv'
NX_DATA_PATH          = r'Y:\nx_Daten'
PULS_DATA_PATH        = r'Y:\PULS_Daten\ZSicherung'
VIBRATION_PATH        = r'Y:\PULS_Daten\Transfer\Vibration_Surveys_CF34'
PULS_ENGINE_TYPES     = {"CF34-10", "CF34-8E", "CF34-8C", "PW300", "PW500"}

def load_sap_data():
    """Liest SAP-CSV, filtert ±3 Wochen B3-Datum + Engine-Typen, dedupliziert."""
    import pandas as _pd
    from datetime import datetime as _dt, timedelta as _td
    if not os.path.exists(SAP_DATA_PATH):
        print(f'[SAP] Datei nicht gefunden: {SAP_DATA_PATH}')
        return []
    try:
        df = _pd.read_csv(SAP_DATA_PATH, sep=';', encoding='latin1', on_bad_lines='skip', dtype=str)
        df = df.fillna('')
        for col in ['B3', 'B1', 'B2', 'SA', 'TS', 'TE', 'Customer']:
            if col not in df.columns:
                df[col] = ''
        df['B3_dt'] = _pd.to_datetime(df['B3'], dayfirst=True, errors='coerce')
        df.dropna(subset=['B3_dt'], inplace=True)
        today = _dt.now()
        span  = _td(weeks=3)
        df = df[(df['B3_dt'] >= today - span) & (df['B3_dt'] <= today + span)].copy()
        df = df[df['Engine Type'].str.startswith(('CF34','CFM','PW3','PW5','PW8'), na=False)]
        df = df.drop_duplicates(subset=['Engine Type', 'Engine Serial', 'Project'])
        def _fmt(val):
            try:
                return _pd.to_datetime(val, dayfirst=True).strftime('%Y-%m-%d')
            except Exception:
                return ''
        result = []
        for _, row in df.iterrows():
            result.append({
                'engineType': row.get('Engine Type', ''),
                'esn':        row.get('Engine Serial', ''),
                'workorder':  row.get('Project', ''),
                'customer':   row.get('Customer', ''),
                'date_b3':    _fmt(row.get('B3', '')),
                'date_b1':    _fmt(row.get('B1', '')),
                'date_b2':    _fmt(row.get('B2', '')),
                'date_sa':    _fmt(row.get('SA', '')),
                'date_ts':    _fmt(row.get('TS', '')),
                'date_te':    _fmt(row.get('TE', '')),
            })
        print(f'[SAP] {len(result)} Engines geladen.')
        return result
    except Exception as e:
        print(f'[SAP] Fehler: {e}')
        return []


# ══════════════════════════════════════════════════════════════════
#  TARDIS — Messenger + Rawdata
# ══════════════════════════════════════════════════════════════════

_messenger     = None
_rawdata_cache = {}

def _get_messenger():
    global _messenger
    from mtu.cae.pytardis import Messenger
    try:
        if _messenger is None:
            _messenger = Messenger(
                user=FLOW_CONFIG["TARDIS_USER"],
                host=FLOW_CONFIG["TARDIS_HOST"],
                api_token=FLOW_CONFIG["TARDIS_TOKEN"])
        return _messenger
    except Exception:
        _messenger = Messenger(
            user=FLOW_CONFIG["TARDIS_USER"],
            host=FLOW_CONFIG["TARDIS_HOST"],
            api_token=FLOW_CONFIG["TARDIS_TOKEN"])
        return _messenger


def _tardis_get_rawdata(channelgroup_id, limit=500):
    """Query raw measurement data from TARDIS by channelgroup_id."""
    if channelgroup_id in _rawdata_cache:
        return _rawdata_cache[channelgroup_id]
    from mtu.cae.pytardis import messages, filters
    msgr = _get_messenger()
    qf   = filters.QueryFilter({"ChannelGroup.Id": channelgroup_id})
    dc   = msgr.send(messages.DataReadMessage(qf, source=FLOW_CONFIG["TARDIS_SOURCE"]))
    if len(dc) == 0:
        return {"columns": [], "rows": [], "total_rows": 0}
    df   = dc[0].values.head(limit)
    rows = [[str(round(v, 6)) if isinstance(v, float) else (str(v) if v is not None else "")
             for v in row] for row in df.values.tolist()]
    result = {"columns": list(df.columns), "rows": rows, "total_rows": len(dc[0].values)}
    _rawdata_cache[channelgroup_id] = result
    return result


# ══════════════════════════════════════════════════════════════════
#  DOCUMENT GENERATION — DOCX / XLSX
# ══════════════════════════════════════════════════════════════════

def _replace_in_docx_xml(xml_bytes, placeholder_map):
    text = xml_bytes.decode('utf-8', errors='replace')
    for ph, val in placeholder_map.items():
        text = text.replace(ph, val)
    def fix_paragraph(m):
        para = m.group(0)
        combined = ''.join(_re.findall(r'<w:t[^>]*>([^<]*)</w:t>', para))
        if not any(ph in combined for ph in placeholder_map):
            return para
        new_combined = combined
        for ph, val in placeholder_map.items():
            new_combined = new_combined.replace(ph, val)
        if new_combined == combined:
            return para
        runs = list(_re.finditer(r'<w:t([^>]*)>([^<]*)</w:t>', para))
        if not runs:
            return para
        result = para
        for i, run in enumerate(reversed(runs)):
            if i == len(runs) - 1:
                attrs = run.group(1)
                if 'preserve' not in attrs:
                    attrs = ' xml:space="preserve"'
                result = result[:run.start()] + f'<w:t{attrs}>{new_combined}</w:t>' + result[run.end():]
            else:
                result = result[:run.start()] + '<w:t></w:t>' + result[run.end():]
        return result
    text = _re.sub(r'<w:p[ >].*?</w:p>', fix_paragraph, text, flags=_re.DOTALL)
    return text.encode('utf-8')


def _fill_docx(template_path, placeholder_map):
    buf = BytesIO()
    with _zf.ZipFile(template_path, 'r') as zin:
        with _zf.ZipFile(buf, 'w', _zf.ZIP_DEFLATED) as zout:
            for item in zin.infolist():
                data = zin.read(item.filename)
                if item.filename.endswith('.xml'):
                    data = _replace_in_docx_xml(data, placeholder_map)
                zout.writestr(item, data)
    buf.seek(0)
    return buf.read()


def _fill_xlsx(template_path, placeholder_map):
    import openpyxl
    wb = openpyxl.load_workbook(template_path, keep_links=False)
    for ws in wb.worksheets:
        for row in ws.iter_rows():
            for cell in row:
                if cell.value and isinstance(cell.value, str):
                    new_val = cell.value
                    for ph, val in placeholder_map.items():
                        new_val = new_val.replace(ph, val)
                    if new_val != cell.value:
                        try:
                            cell.value = float(new_val) if '.' in new_val else int(new_val)
                        except (ValueError, TypeError):
                            cell.value = new_val
    buf = BytesIO()
    wb.save(buf)
    buf.seek(0)
    return buf.read()


# ══════════════════════════════════════════════════════════════════
#  PDF PARSING (aus TARDIS_Writer_01.py)
# ══════════════════════════════════════════════════════════════════

# ── Default PDF-Config (WRB / CFM56-Format) ───────────────────────
_DEFAULT_PDF_CONFIG = {
    "header_keywords": ["esn:", "testrun & shipment"],
    "cluster_keyword":  "cluster",
    "engine_skip":      ["outgoing", "testrun & shipment", "shipment",
                         "incoming", "test", "ingoing"],
    "field_labels": [
        {"field": "Engine",   "label": "testrun & shipment", "mode": "engine_cell"},
        {"field": "ESN",      "label": "esn:",               "mode": "standard"},
        {"field": "TSN",      "label": "tsn:",               "mode": "standard"},
        {"field": "CSN",      "label": "csn",                "mode": "standard"},
        {"field": "MTU-WBS",  "label": "mtu-wbs:",           "mode": "standard"},
        {"field": "Cus (SP)", "label": "cus (sp):",          "mode": "standard"},
    ],
}


def _finde_header_tabelle(seiten_tabellen, header_keywords=None):
    """Findet die Header-Tabelle anhand konfigurierbarer Keywords; gibt (seitennum, tabelle) zurück."""
    keywords = [k.lower() for k in (header_keywords or _DEFAULT_PDF_CONFIG["header_keywords"])]
    best_tabelle = None
    best_hits    = 0
    best_seite   = None
    for seitennum, tabellen in seiten_tabellen:
        for tabelle in tabellen:
            hits = set()
            for zeile in tabelle:
                for zelle in zeile:
                    if not zelle: continue
                    t = str(zelle).lower()
                    for kw in keywords:
                        if kw in t: hits.add(kw)
            if len(hits) == len(keywords): return seitennum, tabelle   # alle Keywords → sofort
            if len(hits) > best_hits:
                best_hits = len(hits); best_tabelle = tabelle; best_seite = seitennum
    return best_seite, best_tabelle


def _extrahiere_kopfdaten(seiten_text, seiten_tabellen, pdf_config=None):
    """Extrahiert Kopfdaten mit konfigurierbaren Feld-Labels."""
    cfg         = pdf_config or _DEFAULT_PDF_CONFIG
    field_defs  = cfg.get("field_labels", _DEFAULT_PDF_CONFIG["field_labels"])
    engine_skip = {s.lower() for s in cfg.get("engine_skip", _DEFAULT_PDF_CONFIG["engine_skip"])}
    ergebnis           = {fd["field"]: "–" for fd in field_defs}
    header_page, tabelle = _finde_header_tabelle(seiten_tabellen, cfg.get("header_keywords"))
    if tabelle is None: return ergebnis, None

    all_labels = [fd["label"].lower() for fd in field_defs if fd.get("mode") != "engine_cell"]

    for zeile in tabelle:
        zellen = [str(z).strip() for z in zeile if z and str(z).strip()]
        for z_idx, zell_text in enumerate(zellen):
            zell_lower = zell_text.lower()
            teile      = [t.strip() for t in zell_text.splitlines()]
            for fd in field_defs:
                feld  = fd["field"]
                label = fd["label"].lower()
                mode  = fd.get("mode", "standard")
                if ergebnis[feld] != "–": continue
                if label not in zell_lower: continue
                if mode == "engine_cell":
                    nicht_leer = [t for t in teile if t and t.lower() not in engine_skip]
                    if nicht_leer: ergebnis[feld] = nicht_leer[-1]
                else:
                    for t_idx, teil in enumerate(teile):
                        if label not in teil.lower(): continue
                        inline = _re.sub(r"(?i)" + _re.escape(label), "", teil).strip()
                        if inline: ergebnis[feld] = inline
                        elif t_idx + 1 < len(teile) and teile[t_idx + 1].strip():
                            ergebnis[feld] = teile[t_idx + 1].strip()
                        break
                    if ergebnis[feld] == "–" and z_idx + 1 < len(zellen):
                        kandidat = zellen[z_idx + 1]
                        if not any(lk in kandidat.lower() for lk in all_labels):
                            ergebnis[feld] = kandidat
    return ergebnis, header_page


def _finde_cluster_tabelle(seiten_tabellen, cluster_keyword=None, start_page=None):
    """Findet die Cluster-Parameter-Tabelle anhand eines konfigurierbaren Keywords.
    start_page: Seiten vor dieser Nummer werden übersprungen (Verankerung am Header)."""
    kw = (cluster_keyword or _DEFAULT_PDF_CONFIG["cluster_keyword"]).lower()
    for seitennum, tabellen in seiten_tabellen:
        if start_page is not None and seitennum < start_page:
            continue
        for tabelle in tabellen:
            if not tabelle: continue
            erste_zelle = ""
            for zeile in tabelle:
                for zelle in zeile:
                    if zelle and str(zelle).strip():
                        erste_zelle = str(zelle).strip(); break
                if erste_zelle: break
            erstes_wort = erste_zelle.split()[0].lower() if erste_zelle.split() else ""
            if erstes_wort == kw:
                return seitennum, tabelle
    return None


def _extrahiere_cluster_parameter(seiten_tabellen, cluster_keyword=None, start_page=None):
    """Extrahiert Cluster-Parameter als Dict {Allg | Spez: Wert}."""
    kw      = (cluster_keyword or _DEFAULT_PDF_CONFIG["cluster_keyword"]).lower()
    ergebnis = _finde_cluster_tabelle(seiten_tabellen, kw, start_page=start_page)
    if ergebnis is None: return {}
    _, tabelle = ergebnis
    parameter  = {}
    for zeile in tabelle:
        if not zeile or len(zeile) < 3: continue
        allg = str(zeile[0]).strip() if zeile[0] is not None else ""
        spez = str(zeile[1]).strip() if zeile[1] is not None else ""
        wert = str(zeile[2]).strip() if zeile[2] is not None else ""
        if allg.lower() == kw: continue
        if not allg and not spez and not wert: continue
        if allg or spez:
            schluessel = f"{allg} | {spez}" if (allg and spez) else (allg or spez)
            parameter[schluessel] = wert
    return parameter


def detect_engine_mapping(engine_string, mappings):
    """Findet das passende Mapping anhand pdf_prefix (case-insensitive startswith)."""
    if not engine_string or engine_string == "–": return None
    for m in mappings:
        prefix = m.get("pdf_prefix", "").strip()
        if prefix and engine_string.upper().startswith(prefix.upper()):
            return m
    return None


def _load_engine_mappings():
    try:
        with open(_MAPPINGS_PATH, 'r', encoding='utf-8') as f:
            return json.load(f)
    except FileNotFoundError:
        return []


def _parse_pdf(path, pdf_config=None):
    """Extract header metadata and cluster parameters from a PDF.
    pdf_config=None → WRB-Defaults; pdf_config={...} → Engine-Mapping-Konfiguration."""
    try:
        import pdfplumber
    except ImportError:
        raise ValueError("pdfplumber nicht installiert (pip install pdfplumber)")
    seiten_text, seiten_tabellen = [], []
    with pdfplumber.open(path) as pdf:
        for page in pdf.pages:
            seiten_text.append(page.extract_text() or "")
            seiten_tabellen.append((page.page_number, page.extract_tables() or []))
    cfg                    = pdf_config or _DEFAULT_PDF_CONFIG
    kopfdaten, header_page = _extrahiere_kopfdaten(seiten_text, seiten_tabellen, cfg)
    cluster                = _extrahiere_cluster_parameter(seiten_tabellen, cfg.get("cluster_keyword"), start_page=header_page)
    return {
        "kopfdaten":    kopfdaten,
        "cluster":      cluster,
        "cluster_keys": list(cluster.keys()),
    }


# ══════════════════════════════════════════════════════════════════
#  HYPERBOOST AUTH (aus TARDIS_Writer_01.py)
# ══════════════════════════════════════════════════════════════════

_hb_token_cache = {"token": None}

def _hb_get_token():
    try:
        import httpx
        with httpx.Client(timeout=30) as c:
            r = c.post(f"{FLOW_CONFIG['HB_BASE']}/auth/login",
                       json={"username": FLOW_CONFIG["HB_USER"],
                             "password": FLOW_CONFIG["HB_PASSWORD"]})
            r.raise_for_status()
            _hb_token_cache["token"] = r.json()["access_token"]
        return _hb_token_cache["token"]
    except ImportError:
        raise ValueError("httpx nicht installiert (pip install httpx)")

def _hb_auth_headers():
    return {"Authorization": f"Bearer {_hb_token_cache['token'] or _hb_get_token()}"}


def _check_rule(rule_str, col_map, row):
    """Parse and evaluate a single limit rule against one data row.
    Supports:  CHANNEL > VALUE   /   CHANNEL <= VALUE   /   VALUE < CHANNEL < VALUE
    Returns (True/False/None, message_or_None). None = not applicable (non-numeric or parse error).
    """
    rule = rule_str.strip()
    # Range: VALUE OP CHANNEL OP VALUE  (e.g.  5000 < N1 < 8000)
    m = _re.match(r'^([\d.eE+\-]+)\s*(<=?|>=?)\s*(.+?)\s*(<=?|>=?)\s*([\d.eE+\-]+)$', rule)
    if m:
        lo, op1, ch, op2, hi = float(m.group(1)), m.group(2), m.group(3).strip(), m.group(4), float(m.group(5))
        idx = col_map.get(ch.lower())
        if idx is None:
            return None, f'Spalte "{ch}" nicht gefunden'
        try:
            val = float(row[idx])
        except (ValueError, TypeError):
            return None, None
        ok = (lo < val if op1 == '<' else lo <= val) and (val < hi if op2 == '<' else val <= hi)
        return (True, None) if ok else (False, f'{ch}={val} nicht in [{lo}…{hi}]')
    # Simple: CHANNEL OP VALUE  (e.g.  N1 > 5000)
    m = _re.match(r'^(.+?)\s*(<=?|>=?|==|!=)\s*([\d.eE+\-]+)$', rule)
    if m:
        ch, op, threshold = m.group(1).strip(), m.group(2), float(m.group(3))
        idx = col_map.get(ch.lower())
        if idx is None:
            return None, f'Spalte "{ch}" nicht gefunden'
        try:
            val = float(row[idx])
        except (ValueError, TypeError):
            return None, None
        ok = {'<': val < threshold, '<=': val <= threshold, '>': val > threshold,
              '>=': val >= threshold, '==': val == threshold, '!=': val != threshold}.get(op, True)
        return (True, None) if ok else (False, f'{ch}={val} verletzt {ch}{op}{threshold}')
    return None, f'Regel nicht parsebar: "{rule}"'


def _expand_rules(rules, col_names):
    """Expand wildcard patterns (* ?) in rule channel names to concrete columns via fnmatch."""
    import fnmatch
    expanded = []
    for rule in rules:
        # Simple: CHANNEL OP VALUE
        m = _re.match(r'^(.+?)\s*(<=?|>=?|==|!=)\s*([\d.eE+\-]+)$', rule)
        if m:
            ch_pat, op, val = m.group(1).strip(), m.group(2), m.group(3)
            if '*' in ch_pat or '?' in ch_pat:
                matched = [c for c in col_names
                           if fnmatch.fnmatch(c.lower(), ch_pat.lower())]
                if matched:
                    expanded.extend(f'{c} {op} {val}' for c in matched)
                    continue
        # Range: VALUE OP CHANNEL OP VALUE
        m2 = _re.match(r'^([\d.eE+\-]+)\s*(<=?|>=?)\s*(.+?)\s*(<=?|>=?)\s*([\d.eE+\-]+)$', rule)
        if m2:
            lo, op1, ch_pat, op2, hi = m2.group(1), m2.group(2), m2.group(3).strip(), m2.group(4), m2.group(5)
            if '*' in ch_pat or '?' in ch_pat:
                matched = [c for c in col_names
                           if fnmatch.fnmatch(c.lower(), ch_pat.lower())]
                if matched:
                    expanded.extend(f'{lo} {op1} {c} {op2} {hi}' for c in matched)
                    continue
        expanded.append(rule)
    return expanded


class FlowExecutor:
    def __init__(self, nodes, connections):
        self.nodes       = nodes
        self.connections = connections

    def run(self):
        order   = self._topological_sort()
        outputs = {}
        skipped = set()   # node IDs whose execution path was not taken

        for node in order:
            node_id = node['id']

            # Propagation: if every feeding node is skipped → skip this one too
            feeding = {c['fromNodeId'] for c in self.connections if c['toNodeId'] == node_id}
            if feeding and feeding.issubset(skipped):
                skipped.add(node_id)
                outputs[node_id] = {'status': 'skipped'}
                continue

            inputs = self._collect_inputs(node, outputs)
            try:
                result = self._dispatch(node, inputs)
                outputs[node_id] = {'status': 'success', 'output': result}

                # Branch: mark direct children on the non-taken port as skipped
                if node['typeId'] == 'branch' and isinstance(result, dict):
                    condition_met = result.get('__branch_result__', False)
                    skipped_port  = 1 if condition_met else 0  # port 0=true, port 1=false
                    for c in self.connections:
                        if c['fromNodeId'] == node_id and c.get('fromPortIdx', 0) == skipped_port:
                            skipped.add(c['toNodeId'])

            except Exception as e:
                outputs[node_id] = {'status': 'error', 'error': str(e)}
        return outputs

    # ── topological sort (Kahn's algorithm) ──────────────────────
    def _topological_sort(self):
        in_deg = {n['id']: 0 for n in self.nodes}
        for c in self.connections:
            in_deg[c['toNodeId']] += 1
        queue  = [n for n in self.nodes if in_deg[n['id']] == 0]
        result = []
        while queue:
            node = queue.pop(0)
            result.append(node)
            for c in self.connections:
                if c['fromNodeId'] == node['id']:
                    in_deg[c['toNodeId']] -= 1
                    if in_deg[c['toNodeId']] == 0:
                        queue.append(next(n for n in self.nodes
                                          if n['id'] == c['toNodeId']))
        return result

    def _collect_inputs(self, node, outputs):
        inputs = {}
        for c in self.connections:
            if c['toNodeId'] != node['id']:
                continue
            src = outputs.get(c['fromNodeId'], {})
            port_name = str(c['toPortIdx'])
            value = src.get('output')
            # Unwrap branch pass-through so downstream nodes receive the original data
            if isinstance(value, dict) and '__branch_result__' in value:
                value = value.get('data')
            if port_name in inputs:
                # Multiple connections to same port → collect as list
                existing = inputs[port_name]
                if isinstance(existing, list):
                    existing.append(value)
                else:
                    inputs[port_name] = [existing, value]
            else:
                inputs[port_name] = value
        return inputs

    def _dispatch(self, node, inputs):
        fn = {
            'api':          self._exec_api,
            'file_read':    self._exec_file_read,
            'file_write':   self._exec_file_write,
            'select':       self._exec_select,
            'filter':       self._exec_filter,
            'transpose':    self._exec_transpose,
            'check':        self._exec_check,
            'recipe':       self._exec_recipe,
            'scan_query':   self._exec_scan_query,
            'cert_build':   self._exec_cert_build,
            'cipa_cert':    self._exec_cipa_cert,
            'branch':       self._exec_branch,
            'merge':        self._exec_merge,
            'pdf_source':   self._exec_pdf_source,
            'tardis_query': self._exec_tardis_query,
            'hb_read':      self._exec_hb_read,
            'hb_write':     self._exec_hb_write,
            'hb_patch':     self._exec_hb_patch,
            'template_steps':  self._exec_template_steps,
            'custom_steps':    self._exec_custom_steps,
            'cluster_check':   self._exec_cluster_check,
            'display':      self._exec_display,
            'note':         lambda cfg, inputs: None,
            'group':        lambda cfg, inputs: None,
            'code':         self._exec_python_eval,
            'plot':         self._exec_plot,
            'pdf_compose':  self._exec_pdf_compose,
            'teams_notify': self._exec_teams_notify,
        }.get(node['typeId'])
        if fn is None:
            raise ValueError(f"Unbekannter Node-Typ: {node['typeId']}")
        return fn(node.get('config', {}), inputs)

    # ── generic executors ────────────────────────────────────────

    def _exec_api(self, cfg, inputs):
        import httpx, json as _json
        url    = cfg.get('url', '').strip()
        if not url:
            raise ValueError('URL ist nicht konfiguriert')
        method  = cfg.get('method', 'GET').upper()
        api_key = cfg.get('apiKey', '').strip()
        params_raw = cfg.get('queryParams', '').strip()   # "key=val&key2=val2"
        body_raw   = cfg.get('body', '').strip()          # JSON string
        resp_path  = cfg.get('responsePath', '').strip()  # e.g. "data.items"

        headers = {}
        if api_key:
            headers['Authorization'] = api_key if api_key.startswith('Bearer ') else f'Bearer {api_key}'

        params = {}
        for part in params_raw.split('&'):
            if '=' in part:
                k, v = part.split('=', 1)
                params[k.strip()] = v.strip()

        body = None
        if body_raw:
            try:
                body = _json.loads(body_raw)
                headers.setdefault('Content-Type', 'application/json')
            except _json.JSONDecodeError as e:
                raise ValueError(f'Body ist kein gültiges JSON: {e}')

        with httpx.Client(timeout=30) as c:
            if method == 'GET':
                r = c.get(url, params=params or None, headers=headers)
            elif method == 'POST':
                r = c.post(url, json=body, params=params or None, headers=headers)
            elif method == 'PUT':
                r = c.put(url, json=body, params=params or None, headers=headers)
            elif method == 'DELETE':
                r = c.delete(url, params=params or None, headers=headers)
            else:
                raise ValueError(f'HTTP-Methode nicht unterstützt: {method}')
            r.raise_for_status()

        try:
            data = r.json()
        except Exception:
            data = r.text

        # Optional: extract nested path (e.g. "data.items")
        if resp_path and isinstance(data, dict):
            for key in resp_path.split('.'):
                if isinstance(data, dict) and key in data:
                    data = data[key]
                else:
                    break

        return data

    def _exec_file_read(self, cfg, inputs):
        import pandas as pd
        path      = cfg.get('filePath', '').strip()
        file_type = cfg.get('fileType', 'csv').lower()
        sheet     = cfg.get('sheet', '').strip() or 0
        delimiter = cfg.get('delimiter', ',').strip() or ','
        if not path:
            raise ValueError('Dateipfad nicht konfiguriert')
        if not os.path.exists(path):
            raise ValueError(f'Datei nicht gefunden: {path}')
        if file_type == 'csv':
            df = pd.read_csv(path, sep=delimiter, dtype=str)
        elif file_type == 'json':
            df = pd.read_json(path, dtype=str)
            if not isinstance(df, pd.DataFrame):
                df = pd.DataFrame(df)
        elif file_type in ('xlsx', 'xls'):
            df = pd.read_excel(path, sheet_name=sheet, dtype=str)
        else:
            raise ValueError(f'Unbekannter Dateityp: {file_type}')
        df = df.where(df.notna(), other=None)
        return {'columns': list(df.columns), 'rows': df.to_dict(orient='records')}

    def _exec_file_write(self, cfg, inputs):
        import pandas as pd
        data      = inputs.get('0')
        path      = cfg.get('filePath', '').strip()
        file_type = cfg.get('fileType', 'csv').lower()
        sheet     = cfg.get('sheet', '').strip() or 'Sheet1'
        delimiter = cfg.get('delimiter', ',').strip() or ','
        if not path:
            raise ValueError('Dateipfad nicht konfiguriert')
        if not data:
            raise ValueError('Kein Input verbunden (Port 0)')
        if isinstance(data, dict) and 'rows' in data:
            df = pd.DataFrame(data['rows'], columns=data.get('columns'))
        elif isinstance(data, list):
            df = pd.DataFrame(data)
        else:
            raise ValueError('Input-Format nicht unterstützt — erwartet {columns, rows} oder Liste')
        os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
        if file_type == 'csv':
            df.to_csv(path, sep=delimiter, index=False)
        elif file_type == 'json':
            df.to_json(path, orient='records', indent=2, force_ascii=False)
        elif file_type in ('xlsx', 'xls'):
            df.to_excel(path, sheet_name=sheet, index=False)
        else:
            raise ValueError(f'Unbekannter Dateityp: {file_type}')
        return {'ok': True, 'path': path, 'rows': len(df), 'columns': len(df.columns)}

    def _exec_python_eval(self, cfg, inputs):
        code = cfg.get('code', '').strip()
        if not code:
            raise ValueError('Kein Code definiert')
        data = inputs.get('0')
        namespace = {'data': data, 'inputs': inputs, 'result': None}
        try:
            import pandas as _pd
            namespace['pd'] = _pd
        except ImportError:
            pass
        try:
            import numpy as _np
            namespace['np'] = _np
        except ImportError:
            pass
        exec(compile(code, '<code-node>', 'exec'), namespace)
        return namespace.get('result')

    def _exec_merge(self, cfg, inputs):
        a        = inputs.get('0') or []
        b        = inputs.get('1') or []
        strategy = cfg.get('strategy', 'concat')
        if not isinstance(a, list): a = [a]
        if not isinstance(b, list): b = [b]
        if strategy == 'concat':
            return a + b
        if strategy == 'zip':
            return [{**ra, **rb} for ra, rb in zip(a, b)]
        if strategy == 'join-key':
            key = cfg.get('joinKey', '').strip()
            if not key:
                raise ValueError('Join Key nicht gesetzt')
            b_map = {str(r.get(key)): r for r in b}
            return [{**r, **b_map.get(str(r.get(key)), {})} for r in a]
        return a + b

    def _exec_display(self, cfg, inputs):
        return inputs.get('0')

    def _exec_plot(self, cfg, inputs):
        import plotly.graph_objects as go
        import pandas as pd

        raw        = inputs.get('0') or {}
        df         = pd.DataFrame(raw.get('rows', []), columns=raw.get('columns', []))
        title      = cfg.get('title', '').strip()
        chart_type = cfg.get('chartType', 'line')
        x_col      = cfg.get('xAxis', '').strip()
        y_cols_raw = cfg.get('yAxes', '').strip()
        limit_min  = cfg.get('limitMin', '')
        limit_max  = cfg.get('limitMax', '')

        meta     = {'_Code', '_ScanNr', '_timestamp'}
        num_cols = [c for c in df.columns
                    if c not in meta and pd.to_numeric(df[c], errors='coerce').notna().any()]
        y_cols   = [c.strip() for c in y_cols_raw.split(',') if c.strip() in df.columns] \
                   if y_cols_raw else num_cols[:5]
        x_vals   = df[x_col].tolist() if x_col and x_col in df.columns else list(range(len(df)))

        fig = go.Figure()
        for col in y_cols:
            y_vals = pd.to_numeric(df[col], errors='coerce').tolist()
            mode   = 'markers' if chart_type == 'scatter' else 'lines+markers'
            fig.add_trace(go.Scatter(x=x_vals, y=y_vals, mode=mode, name=col))

        for lim, label in [(limit_min, 'Min'), (limit_max, 'Max')]:
            if lim != '':
                try:
                    fig.add_hline(y=float(lim), line_dash='dash', line_color='#f87171',
                                  annotation_text=f'{label} {lim}')
                except ValueError:
                    pass

        fig.update_layout(
            title=title or None,
            paper_bgcolor='#2d2d2d', plot_bgcolor='#1e1e1e',
            font=dict(color='rgba(255,255,255,0.75)', size=10),
            margin=dict(l=50, r=20, t=40 if title else 20, b=40),
            legend=dict(bgcolor='rgba(0,0,0,0)', bordercolor='rgba(255,255,255,0.1)'),
            xaxis=dict(title=x_col or 'Index', gridcolor='rgba(255,255,255,0.08)', rangemode='tozero'),
            yaxis=dict(gridcolor='rgba(255,255,255,0.08)'),
            height=280,
        )
        import json as _json
        return {'__plot__': True, 'plotly_json': _json.loads(fig.to_json())}

    def _exec_pdf_compose(self, cfg, inputs):
        import plotly.io as pio
        import plotly.graph_objects as go
        from reportlab.lib.pagesizes import A4
        from reportlab.lib import colors
        from reportlab.platypus import (SimpleDocTemplate, Spacer,
                                        Table, TableStyle, Paragraph, Flowable)
        from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
        from reportlab.lib.units import mm
        from io import BytesIO
        from pypdf import PdfReader, PdfWriter, Transformation
        import datetime, uuid as _uuid

        page_w, page_h = A4
        margin   = 15 * mm
        usable_w = page_w - 2 * margin
        header_h = 30 * mm

        # ── Config ────────────────────────────────────────────────────
        title       = cfg.get('title',       '').strip() or 'Report'
        subtitle    = cfg.get('subtitle',    '').strip()
        plots_pp    = max(1, min(3, int(cfg.get('plots_per_page', 2) or 2)))
        max_cols    = int(cfg.get('table_max_cols', 20) or 20)
        meta_author = cfg.get('meta_author', '').strip()

        # ESN/WO: auto from meta port (0) if connected, else manual config
        meta_raw = inputs.get('0') or {}
        if isinstance(meta_raw, dict) and meta_raw:
            meta_esn = str(meta_raw.get('esn') or meta_raw.get('ESN') or
                           meta_raw.get('engine_serial_number') or
                           cfg.get('meta_esn', '')).strip()
            meta_wo  = str(meta_raw.get('buildnumber') or meta_raw.get('wo') or
                           meta_raw.get('WO') or cfg.get('meta_wo', '')).strip()
        else:
            meta_esn = cfg.get('meta_esn', '').strip()
            meta_wo  = cfg.get('meta_wo',  '').strip()

        meta_date = datetime.date.today().strftime('%d.%m.%Y')
        section_labels = cfg.get('section_labels', {})

        # ── Collect sections (ports 1–8) ──────────────────────────────
        sections = []
        for i in range(1, 9):
            data = inputs.get(str(i))
            if data is None:
                continue
            sections.append({'data': data, 'label': section_labels.get(str(i), '').strip()})

        if not sections:
            raise ValueError('Keine Sektionen verbunden — Plot- oder Display-Nodes an Ports 1–8 anschließen')

        # ── Header callback (drawn on every page) ─────────────────────
        meta_parts = []
        if meta_esn:    meta_parts.append(f'ESN: {meta_esn}')
        if meta_wo:     meta_parts.append(f'WO: {meta_wo}')
        meta_parts.append(f'Datum: {meta_date}')
        if meta_author: meta_parts.append(f'Erstellt von: {meta_author}')
        meta_line = '  │  '.join(meta_parts)

        def draw_header(canvas, doc):
            canvas.saveState()
            canvas.setFillColor(colors.HexColor('#003DA5'))
            canvas.rect(0, page_h - header_h, page_w, header_h, fill=1, stroke=0)
            canvas.setFillColor(colors.HexColor('#009FE3'))
            canvas.rect(0, page_h - header_h, 4, header_h, fill=1, stroke=0)
            canvas.setFillColor(colors.white)
            canvas.setFont('Helvetica-Bold', 13)
            canvas.drawString(margin, page_h - 14*mm, title)
            if subtitle:
                canvas.setFont('Helvetica', 9)
                canvas.setFillColor(colors.HexColor('#cccccc'))
                canvas.drawString(margin, page_h - 21*mm, subtitle)
            canvas.setFont('Helvetica', 8)
            canvas.setFillColor(colors.HexColor('#aaaaaa'))
            canvas.drawString(margin, page_h - 27*mm, meta_line)
            canvas.setFont('Helvetica', 7)
            canvas.drawRightString(page_w - margin, page_h - 27*mm, f'Seite {doc.page}')
            canvas.restoreState()

        rl_buf = BytesIO()
        doc = SimpleDocTemplate(
            rl_buf, pagesize=A4,
            topMargin=header_h + 6*mm, bottomMargin=15*mm,
            leftMargin=margin, rightMargin=margin,
        )

        styles = getSampleStyleSheet()
        lbl_style = ParagraphStyle('SecLabel', parent=styles['Normal'],
                                   fontSize=9, fontName='Helvetica-Bold',
                                   textColor=colors.HexColor('#222222'),
                                   spaceBefore=6, spaceAfter=3)

        plot_h_map = {1: 130*mm, 2: 88*mm, 3: 62*mm}
        plot_h = plot_h_map.get(plots_pp, 88*mm)

        # ── Plot slot: transparent placeholder that records its page position ──
        class _PlotSlot(Flowable):
            def __init__(self, width, height, fig):
                super().__init__()
                self.width  = width
                self.height = height
                self._fig   = fig
                self.recorded_page = None
                self.recorded_x    = 0.0
                self.recorded_y    = 0.0

            def draw(self):
                try:
                    m = self.canv._currentMatrix   # (a,b,c,d,tx,ty)
                    self.recorded_x = float(m[4])
                    self.recorded_y = float(m[5])
                except Exception:
                    pass
                self.recorded_page = self.canv.getPageNumber()
                # Transparent — leave area empty for vector overlay

        plot_slots = []

        def render_item(item, story):
            if isinstance(item, dict) and item.get('__plot__'):
                try:
                    fig = go.Figure(item['plotly_json'])
                    slot = _PlotSlot(width=usable_w, height=plot_h, fig=fig)
                    plot_slots.append(slot)
                    story.append(slot)
                except Exception as e:
                    story.append(Paragraph(f'Plot-Fehler: {e}', styles['Normal']))
            elif isinstance(item, dict) and 'columns' in item and 'rows' in item:
                cols  = item['columns'][:max_cols]
                rows  = [[str(v)[:40] for v in row[:max_cols]] for row in item.get('rows', [])]
                col_w = usable_w / max(len(cols), 1)
                tbl   = Table([cols] + rows, colWidths=[col_w]*len(cols), repeatRows=1)
                tbl.setStyle(TableStyle([
                    ('BACKGROUND',     (0,0), (-1,0),  colors.HexColor('#2d2d2d')),
                    ('TEXTCOLOR',      (0,0), (-1,0),  colors.white),
                    ('FONTNAME',       (0,0), (-1,0),  'Helvetica-Bold'),
                    ('FONTSIZE',       (0,0), (-1,-1), 7),
                    ('ROWBACKGROUNDS', (0,1), (-1,-1), [colors.white, colors.HexColor('#f0f0f0')]),
                    ('GRID',           (0,0), (-1,-1), 0.3, colors.HexColor('#cccccc')),
                    ('TOPPADDING',     (0,0), (-1,-1), 2),
                    ('BOTTOMPADDING',  (0,0), (-1,-1), 2),
                    ('LEFTPADDING',    (0,0), (-1,-1), 3),
                ]))
                story.append(tbl)
            story.append(Spacer(1, 3*mm))

        story = []
        for sec in sections:
            data  = sec['data']
            label = sec['label']
            if label:
                story.append(Paragraph(label, lbl_style))
            if isinstance(data, list):
                for item in data:
                    render_item(item, story)
            else:
                render_item(data, story)
            story.append(Spacer(1, 4*mm))

        # ── Phase 1: build reportlab PDF (plots are transparent placeholders) ──
        doc.build(story, onFirstPage=draw_header, onLaterPages=draw_header)
        rl_buf.seek(0)

        # ── Phase 2: overlay kaleido vector plots with pypdf ─────────────────
        rl_reader = PdfReader(rl_buf)
        writer    = PdfWriter()

        # Group slots by 0-indexed page
        slots_by_page = {}
        for slot in plot_slots:
            if slot.recorded_page is not None:
                slots_by_page.setdefault(slot.recorded_page - 1, []).append(slot)

        for page_idx, rl_page in enumerate(rl_reader.pages):
            page_slots = slots_by_page.get(page_idx, [])
            if not page_slots:
                writer.add_page(rl_page)
                continue

            # Blank canvas at A4 size
            pw = float(rl_page.mediabox.width)
            ph = float(rl_page.mediabox.height)
            base = writer.add_blank_page(width=pw, height=ph)

            for slot in page_slots:
                try:
                    fig = slot._fig
                    fig.update_layout(
                        paper_bgcolor='white', plot_bgcolor='#f5f5f5',
                        font=dict(color='#222222', size=10),
                        margin=dict(l=55, r=20, t=35, b=45),
                    )
                    # kaleido PDF: input pixels at 96 DPI → output points at 72 DPI
                    # 1 pt = 4/3 px  →  to get pt_w output: pass px = pt_w * 4/3
                    k_w = int(slot.width  * 4.0 / 3.0) + 2
                    k_h = int(slot.height * 4.0 / 3.0) + 2
                    fig_pdf_bytes = pio.to_image(fig, format='pdf', width=k_w, height=k_h)

                    fig_reader = PdfReader(BytesIO(fig_pdf_bytes))
                    fig_pg     = fig_reader.pages[0]

                    fig_pw = float(fig_pg.mediabox.width)
                    fig_ph = float(fig_pg.mediabox.height)
                    sx = slot.width  / fig_pw if fig_pw else 1.0
                    sy = slot.height / fig_ph if fig_ph else 1.0

                    # Scale to slot size, then translate to slot position on page
                    t = Transformation().scale(sx, sy).translate(
                        slot.recorded_x, slot.recorded_y)
                    fig_pg.add_transformation(t)
                    fig_pg.mediabox = rl_page.mediabox   # clip to A4

                    base.merge_page(fig_pg, over=True)   # vector plot on blank
                except Exception:
                    pass   # failed plot → slot stays empty; header/labels still render

            # reportlab content (header, labels, tables) on top
            base.merge_page(rl_page, over=True)

        # ── Save to reports/ with readable filename ───────────────────────────
        import re as _re2
        reports_dir = os.path.join(os.path.dirname(__file__), 'reports')
        os.makedirs(reports_dir, exist_ok=True)

        retention_days = int(_load_settings().get('pdf_retention_days', 14))
        cutoff = datetime.datetime.now() - datetime.timedelta(days=retention_days)
        for old in os.listdir(reports_dir):
            if not old.endswith('.pdf'):
                continue
            old_path = os.path.join(reports_dir, old)
            try:
                if datetime.datetime.fromtimestamp(os.path.getmtime(old_path)) < cutoff:
                    os.remove(old_path)
            except OSError:
                pass

        def _safe(s): return _re2.sub(r'[^a-zA-Z0-9_-]', '_', s.strip())[:30] if s else ''
        run_id     = _uuid.uuid4().hex[:6]
        date_str   = datetime.date.today().strftime('%Y-%m-%d')
        name_parts = [p for p in [_safe(meta_esn), _safe(meta_wo), date_str, _safe(title), run_id] if p]
        pdf_name   = '_'.join(name_parts) + '.pdf'
        pdf_path   = os.path.join(reports_dir, pdf_name)

        final_buf = BytesIO()
        writer.write(final_buf)
        with open(pdf_path, 'wb') as f:
            f.write(final_buf.getvalue())

        return {
            '__pdf_result__': True,
            'download_url':   f'/api/flow/pdf_download/{pdf_name}',
            'filename':       pdf_name,
            'sections':       len(sections),
            'esn': meta_esn, 'wo': meta_wo,
            'title': title,
        }

    def _exec_select(self, cfg, inputs):
        data     = inputs.get('0')
        if data is None:
            raise ValueError('Keine Eingangsdaten')
        raw_cols = cfg.get('columns', '').strip()
        if not raw_cols:
            return data  # kein Filter → pass-through
        wanted = [c.strip() for c in raw_cols.replace('\n', ',').split(',') if c.strip()]
        # TARDIS-Format {columns, rows}
        if isinstance(data, dict) and 'columns' in data and 'rows' in data:
            src_cols = data['columns']
            idxs = []
            for w in wanted:
                for i, c in enumerate(src_cols):
                    if c.lower() == w.lower():
                        idxs.append(i)
                        break
            if not idxs:
                raise ValueError(f'Keine der Spalten gefunden: {wanted}. Verfügbar: {src_cols}')
            new_cols = [src_cols[i] for i in idxs]
            new_rows = [[row[i] for i in idxs] for row in data['rows']]
            return {'columns': new_cols, 'rows': new_rows, 'total_rows': data.get('total_rows', len(new_rows))}
        # Array-of-objects Fallback
        if isinstance(data, list):
            return [{k: r[k] for k in wanted if k in r} for r in data]
        return data

    def _exec_filter(self, cfg, inputs):
        import re as _re
        data = inputs.get('0')
        if data is None:
            raise ValueError('Keine Eingangsdaten')
        raw = cfg.get('rules', '').strip()
        if not raw:
            return data
        rules = [r.strip() for r in raw.splitlines()
                 if r.strip() and not r.strip().startswith('#')]
        if not rules:
            return data

        def _coerce(v):
            try: return float(v)
            except (TypeError, ValueError): return v

        def _test(v, op, t):
            v, t = _coerce(v), _coerce(t)
            if op == '>':  return v > t
            if op == '<':  return v < t
            if op == '>=': return v >= t
            if op == '<=': return v <= t
            if op == '==': return v == t or str(v) == str(t)
            if op == '!=': return v != t and str(v) != str(t)
            return True

        def _row_passes(get):
            for rule in rules:
                # Range: lo <[=] col <[=] hi
                m = _re.match(r'^([\d.eE+\-]+)\s*(<=?)\s*(\w+)\s*(<=?)\s*([\d.eE+\-]+)$', rule)
                if m:
                    lo, lop, col, rop, hi = m.groups()
                    v = _coerce(get(col))
                    lo_ok = float(lo) < v if lop == '<' else float(lo) <= v
                    hi_ok = v < float(hi) if rop == '<' else v <= float(hi)
                    if not (lo_ok and hi_ok): return False
                    continue
                # Standard: col op value
                m = _re.match(r'^(\w+)\s*(>=|<=|!=|==|>|<)\s*(.+)$', rule)
                if m:
                    col, op, val = m.group(1), m.group(2), m.group(3).strip().strip('"\'')
                    if not _test(get(col), op, val): return False
                    continue
                raise ValueError(f"Regel nicht erkannt: '{rule}'")
            return True

        # TARDIS format {columns, rows}
        if isinstance(data, dict) and 'columns' in data and 'rows' in data:
            cols    = data['columns']
            col_idx = {c.lower(): i for i, c in enumerate(cols)}

            def _get_col(col_name):
                idx = col_idx.get(col_name.lower())
                if idx is None:
                    raise ValueError(f"Spalte '{col_name}' nicht gefunden. Verfügbar: {cols}")
                return idx

            # validate columns exist before filtering
            for rule in rules:
                m = _re.match(r'^[\d.eE+\-]+\s*<=?\s*(\w+)\s*<=?', rule)
                if m: _get_col(m.group(1))
                m = _re.match(r'^(\w+)\s*(>=|<=|!=|==|>|<)', rule)
                if m: _get_col(m.group(1))

            filtered = [row for row in data['rows']
                        if _row_passes(lambda c, r=row: r[_get_col(c)])]
            return {'columns': cols, 'rows': filtered, 'total_rows': len(filtered)}

        # list-of-dicts fallback
        if isinstance(data, list) and data and isinstance(data[0], dict):
            return [r for r in data if _row_passes(lambda c, row=r: row.get(c))]

        return data

    def _exec_transpose(self, cfg, inputs):
        data = inputs.get('0')
        if data is None:
            raise ValueError('Keine Eingangsdaten')
        if not (isinstance(data, dict) and 'columns' in data and 'rows' in data):
            raise ValueError('Transponieren erfordert TARDIS-Format {columns, rows}')
        cols      = data['columns']
        rows      = data['rows']
        label_col = cfg.get('labelCol', 'Kanal').strip() or 'Kanal'
        # New columns: label column + one column per original row
        new_cols  = [label_col] + [str(i) for i in range(len(rows))]
        # New rows: one per original column, first cell = column name
        new_rows  = [[cols[i]] + [row[i] for row in rows] for i in range(len(cols))]
        return {'columns': new_cols, 'rows': new_rows, 'total_rows': len(new_rows)}

    def _exec_check(self, cfg, inputs):
        data   = inputs.get('0')
        recipe = inputs.get('1')  # optional recipe from recipe-node

        # ── Recipe-Modus: Multi-Code Scan-Check ──────────────────────────
        if recipe and isinstance(recipe, dict):
            test_id = (data or {}).get('test_id', '').strip() if isinstance(data, dict) else ''
            engine_type = recipe.get('engine_type', '')
            if not test_id:
                raise ValueError('Keine Test-ID — TARDIS Query (Port 0) mit th_test_id konfigurieren')
            all_data = _batch_scan_check_internal(test_id, engine_type)

            # Auto-select: highest green scan per code, fallback to highest scan nr
            # Invalidate manual selection if test_id changed since last manual pick
            selected_scans = cfg.get('selected_scans', {})
            auto_selected  = not bool(selected_scans) or cfg.get('_last_test_id', '') != test_id
            if auto_selected:
                best = {}
                for code, scans in all_data.items():
                    if code == '_meta':
                        continue
                    scan_nrs = [k for k in scans if not k.startswith('_')]
                    if not scan_nrs:
                        continue
                    scan_nrs_sorted = sorted(scan_nrs, key=lambda x: float(x) if x.replace('.','',1).isdigit() else 0, reverse=True)
                    green = [s for s in scan_nrs_sorted if scans[s].get('__ok', False)]
                    best[code] = green[0] if green else scan_nrs_sorted[0]
                selected_scans = best

            results    = {}
            overall_ok = True
            for code, scan_nr in selected_scans.items():
                scan_nr_str = str(scan_nr)
                code_data   = all_data.get(code, {})
                scan_vals   = code_data.get(scan_nr_str, {})
                ok          = scan_vals.get('__ok', True)
                violations  = scan_vals.get('__violations', [])
                results[code] = {
                    'scan_nr':    scan_nr,
                    'ok':         ok,
                    'violations': violations,
                    'values':     {k: v for k, v in scan_vals.items() if not k.startswith('__')}
                }
                if not ok:
                    overall_ok = False

            # Build full per-code scan overview (mirrors config panel green/red)
            all_scans = {}
            for code, scans in all_data.items():
                if code == '_meta':
                    continue
                all_scans[code] = {
                    nr: {'ok': v.get('__ok', True), 'violations': v.get('__violations', [])}
                    for nr, v in scans.items() if not nr.startswith('_')
                }

            return {
                'type':          'scan_check_result',
                'engine_type':   engine_type,
                'test_id':       test_id,
                'overall_ok':    overall_ok,
                'auto_selected': auto_selected,
                'best_scans':    selected_scans,
                'results':       results,
                'all_scans':     all_scans,
            }

        # ── Manueller Modus: rule-based row check ────────────────────────
        if data is None:
            raise ValueError('Keine Eingangsdaten')
        if not (isinstance(data, dict) and 'columns' in data and 'rows' in data):
            raise ValueError('Eingabe muss TARDIS-Format {columns, rows} haben')
        raw_rules = cfg.get('rules', '').strip()
        if not raw_rules:
            return data  # kein Check → pass-through
        rules   = [r.strip() for r in raw_rules.splitlines()
                   if r.strip() and not r.strip().startswith('#')]
        cols    = data['columns']
        rules   = _expand_rules(rules, cols)
        col_map = {c.lower(): i for i, c in enumerate(cols)}
        new_rows = []
        for row in data['rows']:
            violations = []
            for rule in rules:
                ok, msg = _check_rule(rule, col_map, row)
                if ok is False and msg:
                    violations.append(msg)
                elif ok is None and msg:
                    violations.append(f'⚠︎{msg}')
            status = 'FAIL' if any(v for v in violations if not v.startswith('⚠')) else 'PASS'
            new_rows.append(row + [status, '; '.join(violations)])
        new_cols = cols + ['__status__', '__violations__']
        return {'columns': new_cols, 'rows': new_rows, 'total_rows': data.get('total_rows', len(new_rows))}

    def _exec_recipe(self, cfg, inputs):
        engine_type = cfg.get('engineType', '').strip()
        if not engine_type:
            raise ValueError('Engine Type nicht konfiguriert')
        recipes = _load_recipes()
        match = next((r for r in recipes if r.get('engine_type', '') == engine_type), None)
        if match is None:
            available = [r.get('engine_type', '?') for r in recipes]
            raise ValueError(f'Rezept "{engine_type}" nicht gefunden. Verfügbar: {available}')
        return match

    # ── TARDIS domain executors ──────────────────────────────────

    def _exec_scan_query(self, cfg, inputs):
        upstream   = inputs.get('0') or {}
        test_id    = (upstream.get('test_id') or cfg.get('testId', '')).strip()
        scan_codes = cfg.get('scanCodes') or []          # list from multi-select panel
        if not scan_codes:                               # fallback: legacy single-code field
            sc = cfg.get('scanCode', '').strip()
            if sc:
                scan_codes = [sc]
        scan_nr = cfg.get('scanNr', '').strip()
        if not test_id:
            raise ValueError('Test-ID nicht konfiguriert — TARDIS Query verbinden oder Test-ID manuell eintragen')
        if not scan_codes:
            raise ValueError('Mindestens einen Scan-Code auswählen')
        from mtu.cae.pytardis import messages, filters
        import pandas as pd
        MEA_NAME = '[measured, measurement absolute]'
        msgr = _get_messenger()
        qf = filters.QueryFilter({"Test.Id": test_id, "Measurement.Name": MEA_NAME})
        dc = msgr.send(messages.DataReadMessage(qf, source=FLOW_CONFIG["TARDIS_SOURCE"]))
        if len(dc) == 0:
            return {"columns": [], "rows": [], "total_rows": 0}
        frames = [item.values for item in dc]
        combined = pd.concat(frames, ignore_index=True)
        if '_Code' not in combined.columns:
            raise ValueError(f'_Code-Spalte fehlt. Gefundene Spalten: {list(combined.columns[:10])}')
        code_df = combined[combined['_Code'].isin(scan_codes)]
        if code_df.empty:
            available = sorted(combined['_Code'].dropna().unique().tolist())
            raise ValueError(f'Kein Scan-Code {scan_codes} gefunden. Verfügbar: {available}')
        if scan_nr and '_ScanNr' in code_df.columns:
            nr_df = code_df[code_df['_ScanNr'].astype(str) == scan_nr]
            if not nr_df.empty:
                code_df = nr_df
        cols = list(code_df.columns)
        rows = [[str(round(v, 6)) if isinstance(v, float) else (str(v) if v is not None else "")
                 for v in row] for row in code_df.values.tolist()]
        return {"columns": cols, "rows": rows, "total_rows": len(code_df)}

    def _exec_cert_build(self, cfg, inputs):
        from mtu.cae.pytardis import messages, filters
        import pandas as pd
        recipe      = inputs.get('0')              # Port 0: recipe node
        tardis_data = inputs.get('1') or {}        # Port 1: tardis_query output (carries test_id)
        metadata    = inputs.get('2')              # Port 2: hb_read output (optional)
        if not recipe or not isinstance(recipe, dict):
            raise ValueError('Recipe (Port 0) nicht verbunden oder ungültig')

        engine_type      = recipe.get('engine_type', '')
        test_id          = (tardis_data.get('test_id') or '').strip()
        cert_type_name   = cfg.get('certTypeName', '').strip()
        esn              = cfg.get('esn', '').strip()
        wbs              = cfg.get('wbs', '').strip()
        selected_scans   = cfg.get('selected_scans', {})    # {code: scanNr_str}
        selected_ratings = cfg.get('selected_ratings', [])  # ["RatingA", "RatingB"]

        if not test_id:
            raise ValueError('Test-ID fehlt — TARDIS Query (Port 1) verbinden und Hierarchy bis zur Test-Ebene auswählen')

        if not selected_scans or cfg.get('_last_test_id', '') != test_id:
            scan_data = _batch_scan_check_internal(test_id, engine_type)
            selected_scans = {}
            for code, scans in scan_data.items():
                if code == '_meta':
                    continue
                scan_nrs = [k for k in scans if not k.startswith('_')]
                if not scan_nrs:
                    continue
                scan_nrs_sorted = sorted(scan_nrs, key=lambda x: float(x) if x.replace('.','',1).isdigit() else 0, reverse=True)
                green = [s for s in scan_nrs_sorted if scans[s].get('__ok', False)]
                selected_scans[code] = green[0] if green else scan_nrs_sorted[0]

        if not selected_ratings:
            raise ValueError('Keine Ratings ausgewählt — bitte mindestens ein Rating in der Cert-Build-Node wählen')

        # Resolve cert_type (global, one for all ratings)
        ct_map = {ct['name']: ct for ct in recipe.get('cert_types', [])}
        if cert_type_name not in ct_map and ct_map:
            cert_type_name = next(iter(ct_map))
        ct       = ct_map.get(cert_type_name)
        template = ct.get('template', '') if ct else ''

        # ── Query TARDIS mit Channel.Name-Filter + avail-Guard (wie scan_preview) ─
        required_scans = recipe.get('required_scans', [])

        # Alle benötigten Kanalnamen sammeln (inkl. Limit-Referenzkanäle)
        all_ch_names = ['_ScanNr', '_Code', '_timestamp']
        for scan_spec in required_scans:
            for p in scan_spec.get('parameters', []):
                ch = p.get('channel', '')
                if ch and ch not in all_ch_names:
                    all_ch_names.append(ch)
                for lim in [p.get('limit_min', ''), p.get('limit_max', '')]:
                    if lim and not _re.match(r'^[\d.eE+\-]+$', str(lim)) and lim not in all_ch_names:
                        all_ch_names.append(lim)

        MEA_NAME = '[measured, measurement absolute]'
        msgr = _get_messenger()
        qf = filters.QueryFilter({"Test.Id": test_id, "Measurement.Name": MEA_NAME})
        qf.append(("Channel.Name", "in", all_ch_names))
        dc = msgr.send(messages.DataReadMessage(qf, source=FLOW_CONFIG["TARDIS_SOURCE"]))
        if len(dc) == 0:
            combined = pd.DataFrame()
        else:
            frames = []
            for item in dc:
                df = item.values
                avail = [c for c in all_ch_names if c in df.columns]
                if avail:
                    frames.append(df[avail])
            combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()

        # Build code_data: {code: {col: val}} for selected scan_nr per code
        # Multiple rows per scan may exist (one per channel group) → aggregate first non-null per column
        # KEY FIX: filter by _ScanNr (present in ALL channel groups) not by _Code first,
        # so that channel values from CG2 (e.g. FNK3=150.0) are found even if CG2 has _Code=NaN
        import math as _math
        _debug_combined_shape = combined.shape if not combined.empty else (0, 0)
        _debug_code_values    = sorted(combined['_Code'].dropna().unique().tolist()) if (not combined.empty and '_Code' in combined.columns) else []
        _debug_columns        = list(combined.columns) if not combined.empty else []
        code_data = {}
        for code, scan_nr in selected_scans.items():
            if combined.empty or '_ScanNr' not in combined.columns:
                code_data[code] = {}
                continue
            # Primary lookup: _ScanNr → captures all ChannelGroups for this scan
            row_df = combined[combined['_ScanNr'].astype(str) == str(scan_nr)]
            if not row_df.empty:
                merged = {}
                for col in row_df.columns:
                    for v in row_df[col]:
                        if v is None or (isinstance(v, float) and _math.isnan(v)):
                            continue
                        merged[col] = str(round(v, 6)) if isinstance(v, float) else str(v)
                        break
                    if col not in merged:
                        merged[col] = ''
                code_data[code] = merged
            else:
                code_data[code] = {}

        def _norm_ph(raw, fallback=''):
            """Normalize placeholder: strip any {{ }} and re-wrap → always {{key}}."""
            s = (raw or '').strip().strip('{}').strip()
            return '{{' + (s or fallback) + '}}'

        # ── Base placeholder map (shared across all ratings) ─────────────
        ph_base = {}

        # Metadata from hb_read (port 2) mapped via recipe.meta_schema.params
        if metadata and isinstance(metadata, dict):
            # Flatten metadata zwei Ebenen tief (analog CIPA node)
            _flat_meta = dict(metadata)
            for _sk in ('parameters', 'PARAMETERS', 'customAttributes',
                        'custom_attributes', 'kopfdaten', 'test_info'):
                _sv = _flat_meta.get(_sk)
                if isinstance(_sv, dict):
                    for _fk, _fv in _sv.items():
                        if _fk not in _flat_meta:
                            _flat_meta[_fk] = _fv
            for _k2, _v2 in list(_flat_meta.items()):
                if isinstance(_v2, dict):
                    for _kk, _vv in _v2.items():
                        if _kk not in _flat_meta:
                            _flat_meta[_kk] = _vv

            meta_params = recipe.get('meta_schema', {}).get('params', [])
            for mp in meta_params:
                ref         = mp.get('ref', '')
                placeholder = mp.get('placeholder', '')
                if not placeholder or not ref:
                    continue
                if '.' in ref:
                    # dot-notation: "test_info.Parameters.IDPLUG"
                    _obj = metadata
                    for _part in ref.split('.'):
                        _obj = _obj.get(_part, '') if isinstance(_obj, dict) else ''
                    val = str(_obj) if _obj != '' else str(_flat_meta.get(ref, ''))
                else:
                    val = str(_flat_meta.get(ref, ''))
                if val:
                    ph_base[_norm_ph(placeholder)] = val
            for k, v in _flat_meta.items():
                if not isinstance(v, (dict, list)):
                    key = '{{' + k + '}}'
                    if key not in ph_base:
                        ph_base[key] = str(v)

        # Config Panel ESN/WBS als expliziter Override (gewinnt nur wenn ausgefüllt)
        if esn: ph_base['{{ESN}}'] = esn
        if wbs: ph_base['{{WBS}}'] = wbs

        # Code-indexed metadata slots (position, label, scanNr, timestamp) — shared
        for i, scan_spec in enumerate(required_scans, 1):
            code  = scan_spec.get('code', '')
            label = scan_spec.get('label', code)
            row   = code_data.get(code, {})
            ph_base[f'{{{{Code{i}}}}}']           = code
            ph_base[f'{{{{Code{i}_Label}}}}']     = label
            ph_base[f'{{{{Code{i}_ScanNr}}}}']    = str(row.get('_ScanNr', selected_scans.get(code, ''))) or '-'
            ph_base[f'{{{{Code{i}_Timestamp}}}}'] = str(row.get('_timestamp', ''))
        for i in range(len(required_scans) + 1, 11):
            ph_base[f'{{{{Code{i}}}}}'] = ''
            ph_base[f'{{{{Code{i}_ScanNr}}}}'] = ''
            ph_base[f'{{{{Code{i}_Timestamp}}}}'] = ''

        # Rating → scan-codes lookup (from recipe.ratings[].scans)
        _rating_scans = {r.get('name', ''): set(r.get('scans', []))
                         for r in recipe.get('ratings', [])}

        def _resolve_limit(raw, row):
            """Return numeric string for a limit: direct number or channel lookup."""
            if not raw:
                return ''
            if _re.match(r'^[\d.eE+\-]+$', str(raw)):
                return str(raw)          # already a number
            return row.get(str(raw), '') # channel reference → look up in scan data

        def _build_rating_params(scan_codes):
            """Build parameter-value placeholders for the given set of scan codes."""
            rph = {}
            for scan_spec in required_scans:
                code = scan_spec.get('code', '')
                if code not in scan_codes:
                    continue
                row = code_data.get(code, {})
                for param in scan_spec.get('parameters', []):
                    ch = param.get('channel', '')
                    if not ch:
                        continue
                    key = _norm_ph(param.get('placeholder', ''), fallback=ch)
                    val = row.get(ch, '')
                    if val or key not in rph:
                        rph[key] = str(val)
                        # Resolve limits: numeric literal or channel reference
                        lo = _resolve_limit(param.get('limit_min', ''), row)
                        hi = _resolve_limit(param.get('limit_max', ''), row)
                        try:
                            fval = float(val); status = 'OK'
                            try:
                                if lo and float(lo) > fval:
                                    status = 'UNTER_LIMIT'
                            except Exception:
                                pass
                            try:
                                if hi and float(hi) < fval:
                                    status = 'UEBER_LIMIT'
                            except Exception:
                                pass
                            rph[_norm_ph(ch + '_STATUS')] = status
                            rph[_norm_ph(ch + '_MIN')]    = lo
                            rph[_norm_ph(ch + '_MAX')]    = hi
                        except (ValueError, TypeError):
                            rph[_norm_ph(ch + '_STATUS')] = ''
                            rph[_norm_ph(ch + '_MIN')]    = lo
                            rph[_norm_ph(ch + '_MAX')]    = hi
            return rph

        # ── Generate ZIP: one document per selected rating, same cert_type ──
        import uuid as _uuid
        run_id   = _uuid.uuid4().hex[:10]
        zip_dir  = os.path.join(os.path.dirname(__file__), 'flows')
        os.makedirs(zip_dir, exist_ok=True)
        zip_path = os.path.join(zip_dir, f'cert_{run_id}.zip')
        ext = os.path.splitext(template)[1].lower() if template else ''
        files_generated = []
        with _zf.ZipFile(zip_path, 'w', _zf.ZIP_DEFLATED) as zf:
            for rating_name in selected_ratings:
                # Each rating gets its own parameter values from its specific scans
                scan_codes = _rating_scans.get(rating_name, {s.get('code', '') for s in required_scans})
                rating_ph  = {**ph_base, **_build_rating_params(scan_codes)}
                rating_ph['{{RATING}}'] = rating_name
                fname = f"{esn or 'cert'}_{rating_name}_{cert_type_name}" if cert_type_name else f"{esn or 'cert'}_{rating_name}"
                if not ct:
                    zf.writestr(fname + '_KEIN_CERT_TYPE.txt',
                                f'CertType "{cert_type_name}" nicht in recipe.cert_types gefunden')
                    continue
                if not template or not os.path.exists(template):
                    zf.writestr(fname + '_KEIN_TEMPLATE.txt', f'Template nicht gefunden:\n{template}')
                    continue
                try:
                    if ext == '.docx':
                        zf.writestr(fname + '.docx', _fill_docx(template, rating_ph))
                    elif ext in ('.xlsx', '.xlsm'):
                        zf.writestr(fname + '.xlsx', _fill_xlsx(template, rating_ph))
                    else:
                        zf.writestr(fname + '_UNBEKANNT.txt', f'Format nicht unterstützt: {ext}')
                        continue
                    files_generated.append(fname + ext)
                except Exception as e:
                    zf.writestr(fname + '_FEHLER.txt', str(e))
            zf.writestr('placeholder_map.json', json.dumps(rating_ph, indent=2, ensure_ascii=False))
            # Build placeholder-loop trace: for each scan_spec, show what row was found and what val each param produced
            _debug_ph_trace = []
            for _ss in required_scans:
                _sc = _ss.get('code', '')
                _row = code_data.get(_sc, {})
                _entry = {
                    'code':      _sc,
                    'row_keys':  list(_row.keys()),
                    'params': [{
                        'ch':  _p.get('channel', ''),
                        'ph':  _p.get('placeholder', ''),
                        'key': _norm_ph(_p.get('placeholder', ''), fallback=_p.get('channel', '')),
                        'val': _row.get(_p.get('channel', ''), '__MISSING__'),
                    } for _p in _ss.get('parameters', []) if _p.get('channel')],
                }
                _debug_ph_trace.append(_entry)
            zf.writestr('_debug.json', json.dumps({
                'combined_shape':     list(_debug_combined_shape),
                'combined_columns':   _debug_columns,
                '_Code_values':       _debug_code_values,
                'selected_scans':     selected_scans,
                'all_ch_names':       all_ch_names,
                'code_data_raw':      code_data,
                'ph_trace':           _debug_ph_trace,
            }, indent=2, ensure_ascii=False))
        return {
            "__cert_result__": True,
            "download_url":    f"/api/flow/cert_download/{run_id}",
            "files":           files_generated,
            "ratings":         list(selected_ratings),
            "esn":             esn,
            "placeholders":    len(ph_base),
        }

    def _exec_cipa_cert(self, cfg, inputs):
        """
        CIPA Certificate node — CFM56-7B ID Plug Adjustment.
        Port 0: HB Read metadata dict
        Port 1: TARDIS Query output (channel dict)
        Config: templatePath
        """
        import os as _os, uuid as _uuid2

        metadata    = inputs.get('0') or {}
        tardis_data = inputs.get('1') or {}

        template_path = cfg.get('templatePath', '').strip()
        if not template_path or not _os.path.exists(template_path):
            raise ValueError(f'CIPA Template nicht gefunden: {template_path!r}')

        cipa_cfg  = _load_cipa_config()
        mapping   = cipa_cfg.get('mapping', [])
        pin_cfg   = cipa_cfg.get('pin_config', {})

        # ── Flatten metadata: merge known nested sub-dicts to top level ─────
        flat_meta = dict(metadata)
        for _subkey in ('parameters', 'PARAMETERS', 'customAttributes',
                        'custom_attributes', 'kopfdaten', 'test_info'):
            _sub = flat_meta.get(_subkey)
            if isinstance(_sub, dict):
                for _k, _v in _sub.items():
                    if _k not in flat_meta:   # top-level wins on conflict
                        flat_meta[_k] = _v
        # Fix 1 — zweiter Pass: Kinder von promoted Sub-Dicts aufnehmen (z.B. Parameters aus test_info)
        for _k2, _v2 in list(flat_meta.items()):
            if isinstance(_v2, dict):
                for _kk, _vv in _v2.items():
                    if _kk not in flat_meta:
                        flat_meta[_kk] = _vv

        # ── Resolve values from mapping ──────────────────────────────────────
        special    = {}   # role → value
        text_ph    = {}   # placeholder → value (text replacements)
        _dbg_rows  = []   # debug trace

        for row in mapping:
            source      = row.get('source', '')
            src_field   = row.get('source_field', '').strip()
            role        = row.get('role', 'text')
            placeholder = row.get('placeholder', '').strip()

            if source == 'hb_read':
                if not src_field:
                    value = ''
                elif '.' in src_field:
                    # Fix 2 — dot-notation: "Parameters.IDPLUG" oder "test_info.Parameters.IDPLUG"
                    _obj = metadata
                    for _part in src_field.split('.'):
                        _obj = _obj.get(_part, '') if isinstance(_obj, dict) else ''
                    value = str(_obj) if _obj != '' else str(flat_meta.get(src_field, ''))
                else:
                    value = str(flat_meta.get(src_field, ''))
            elif source == 'tardis':
                if not src_field:
                    value = ''
                else:
                    cols = tardis_data.get('columns', [])
                    rows = tardis_data.get('rows', [])
                    scan_code = row.get('scan_code', '').strip()
                    try:
                        col_idx  = cols.index(src_field)
                        code_idx = cols.index('_Code') if '_Code' in cols else -1
                        matching = [r for r in rows
                                    if not scan_code or
                                    (code_idx >= 0 and r[code_idx] == scan_code)]
                        value = str(matching[0][col_idx]) if matching else ''
                    except (ValueError, IndexError):
                        value = ''
            else:
                value = ''

            _dbg_rows.append({
                'source':       source,
                'source_field': src_field,
                'scan_code':    row.get('scan_code', ''),
                'role':         role,
                'placeholder':  placeholder,
                'value':        value,
                'found':        value != '',
            })

            if role != 'text':
                # image_* roles: store the placeholder name (the target in the DOCX)
                if role in ('image_before', 'image_after'):
                    special[role] = placeholder
                else:
                    special[role] = value

            # A placeholder may be comma-separated (e.g. engine_config before AND after)
            # Skip image roles — they are replaced with binary image data, not text
            if role not in ('image_before', 'image_after'):
                for ph in [p.strip() for p in placeholder.split(',') if p.strip()]:
                    text_ph[ph] = value

        # ── Validate required special roles ──────────────────────────────────
        trim_before    = special.get('trim_before', '').strip()
        trim_after     = special.get('trim_after', '').strip()
        engine_config  = special.get('engine_config', '').strip()
        pmux           = special.get('pmux', '').strip()
        ph_img_before  = special.get('image_before', '{{CIPA_IMAGE_BEFORE}}').strip() or '{{CIPA_IMAGE_BEFORE}}'
        ph_img_after   = special.get('image_after',  '{{CIPA_IMAGE_AFTER}}').strip()  or '{{CIPA_IMAGE_AFTER}}'

        if not trim_after:
            raise ValueError(
                'Trim-After-Wert fehlt — TARDIS Query verbinden und '
                '"trim_after" Mapping in CIPA Config konfigurieren'
            )

        # ── Generate circle diagrams ──────────────────────────────────────────
        pin_states_before = _cipa_get_pin_states(
            trim_before or None, engine_config or None, pmux or None, pin_cfg
        )
        pin_states_after = _cipa_get_pin_states(
            trim_after, engine_config or None, pmux or None, pin_cfg
        )

        img_before = _cipa_generate_plot(pin_states_before)
        img_after  = _cipa_generate_plot(pin_states_after)

        # ── Fill DOCX ─────────────────────────────────────────────────────────
        try:
            docx_bytes = _fill_docx_with_images(
                template_path,
                text_ph,
                {
                    ph_img_before: img_before,
                    ph_img_after:  img_after,
                }
            )
        except Exception as _fill_err:
            raise ValueError(
                f'DOCX-Template konnte nicht verarbeitet werden: {_fill_err!s} '
                f'(Pfad: {template_path!r}) — '
                'Bitte sicherstellen dass die Datei eine gültige .docx-Datei ist.'
            ) from _fill_err

        # ── Persist als ZIP (gleiche Konvention wie cert_build) ──────────────
        run_id   = _uuid2.uuid4().hex[:10]
        esn      = text_ph.get('{{LB_Header_SN}}', 'unknown')
        wbs      = text_ph.get('{{LB_Header_WO}}', 'unknown')
        filename = f'CFM56-7B_{esn}_{wbs}_CIPA.docx'

        zip_dir  = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), 'flows')
        _os.makedirs(zip_dir, exist_ok=True)
        zip_path = _os.path.join(zip_dir, f'cert_{run_id}.zip')
        import zipfile as _zf2
        with _zf2.ZipFile(zip_path, 'w', _zf2.ZIP_DEFLATED) as zf:
            zf.writestr(filename, docx_bytes)

        return {
            '__cipa_result__': True,
            'download_url': f'/api/flow/cert_download/{run_id}',
            'files':        [filename],
            'esn':          esn,
            'trim_before':  trim_before,
            'trim_after':   trim_after,
            '_debug': {
                'flat_meta_keys':    sorted(str(k) for k in flat_meta.keys()),
                'mapping_resolved':  _dbg_rows,
                'special_roles': {
                    'trim_before':   trim_before,
                    'trim_after':    trim_after,
                    'engine_config': engine_config,
                    'pmux':          pmux,
                },
            },
        }

    def _exec_branch(self, cfg, inputs):
        a         = inputs.get('0') or {}
        b         = inputs.get('1') or {}
        condition = cfg.get('condition', '').strip()
        tbl_mode  = cfg.get('tableMode', 'any').strip().lower()
        if not condition:
            raise ValueError('Keine Bedingung konfiguriert — Bedingung im Branch-Node eingeben')
        # Flat merge (A wins on conflict) + namespaced access via a / b
        merged = {**b, **a, 'a': a, 'b': b}
        condition_met = self._eval_branch_condition(condition, tbl_mode, merged)
        return {
            '__branch_result__': condition_met,
            'condition':         condition,
            'result':            'true' if condition_met else 'false',
            'data':              a or b,
        }

    def _eval_branch_condition(self, condition, tbl_mode, data):
        import re as _re_b
        _safe = {'True': True, 'False': False, 'None': None,
                 'len': len, 'str': str, 'int': int, 'float': float, 'bool': bool,
                 'abs': abs, 'round': round}

        # Check for explicit prefix: any/all/none: <expr>
        m = _re_b.match(r'^(any|all|none)\s*:\s*(.+)$', condition, _re_b.IGNORECASE)
        if m:
            mode, expr = m.group(1).lower(), m.group(2).strip()
        else:
            # Auto-detect: table data has 'rows'+'columns', else treat as dict
            if isinstance(data, dict) and 'rows' in data and 'columns' in data:
                mode, expr = tbl_mode, condition
            else:
                mode, expr = 'dict', condition

        if mode == 'dict':
            # Flatten one level of nesting; keep 'a'/'b' as named dicts for cross-source access
            flat = {}
            if isinstance(data, dict):
                for k, v in data.items():
                    if k in ('a', 'b') and isinstance(v, dict):
                        flat[k] = v          # keep namespace intact
                        flat.update(v)       # also expose keys flat (a wins over b via merge order)
                    elif isinstance(v, dict):
                        flat.update(v)
                    else:
                        flat[k] = v
            try:
                return bool(eval(expr, {'__builtins__': _safe}, flat))  # noqa: S307
            except Exception as e:
                raise ValueError(f'Bedingung konnte nicht ausgewertet werden: {e}')

        # Table modes: any / all / none
        if not isinstance(data, dict) or 'rows' not in data:
            # Fallback: try dict eval
            try:
                return bool(eval(expr, {'__builtins__': _safe}, data if isinstance(data, dict) else {}))  # noqa: S307
            except Exception:
                return False
        cols = data.get('columns', [])
        rows_results = []
        for row in data.get('rows', []):
            row_dict = dict(zip(cols, row))
            try:
                rows_results.append(bool(eval(expr, {'__builtins__': _safe}, row_dict)))  # noqa: S307
            except Exception:
                rows_results.append(False)
        if not rows_results:
            return False
        if mode == 'any':  return any(rows_results)
        if mode == 'all':  return all(rows_results)
        if mode == 'none': return not any(rows_results)
        return False

    def _exec_pdf_source(self, cfg, inputs):
        path = cfg.get('filePath', '').strip()
        if not path:
            raise ValueError('PDF Dateipfad ist nicht konfiguriert')

        mappings   = _load_engine_mappings()
        mapping_id = cfg.get('engineMappingId', '').strip()

        # Mapping per ID manuell gesetzt → direkt verwenden (kein Pass 1)
        if mapping_id:
            mapping = next((m for m in mappings if m.get('id') == mapping_id), None)
            if mapping and mapping.get('pdf_config'):
                return _parse_pdf(path, mapping['pdf_config'])

        # Pass 1: Engine-String mit Defaults extrahieren
        result_pass1 = _parse_pdf(path)
        engine_str   = result_pass1['kopfdaten'].get('Engine', '–')

        # Mapping per Engine-String auto-detektieren
        mapping = detect_engine_mapping(engine_str, mappings)
        if mapping and mapping.get('pdf_config'):
            # Pass 2: mit Mapping-Konfiguration neu parsen
            result = _parse_pdf(path, mapping['pdf_config'])
            result['engine_mapping'] = mapping.get('engine_type', '')
            return result

        # Kein Mapping mit pdf_config → Pass-1-Ergebnis zurückgeben
        result_pass1['engine_mapping'] = mapping.get('engine_type', '') if mapping else ''
        return result_pass1

    def _exec_tardis_query(self, cfg, inputs):
        cg_id   = cfg.get('channelgroupId', '').strip()
        test_id = cfg.get('th_test_id', '').strip()
        limit   = int(cfg.get('limit', 500) or 500)
        if not cg_id:
            raise ValueError('ChannelGroup ID ist nicht konfiguriert')
        result = _tardis_get_rawdata(cg_id, limit=limit)
        if test_id:
            result['test_id'] = test_id
        return result

    def _exec_hb_read(self, cfg, inputs):
        import httpx
        order_id = cfg.get('orderId', '').strip()
        if not order_id:
            raise ValueError('Test Order nicht konfiguriert')
        with httpx.Client(timeout=15) as c:
            # ── 1. Test Order ────────────────────────────────────────────
            r = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-orders",
                      params={"testbed": FLOW_CONFIG["HB_TESTBED"]},
                      headers=_hb_auth_headers())
            r.raise_for_status()
            raw = r.json()
            orders = raw if isinstance(raw, list) else (
                raw.get('orders') or raw.get('items') or raw.get('results') or []
            )
            order = next((o for o in orders
                          if str(o.get('identifier') or o.get('Identifier') or o.get('id', '')) == str(order_id)),
                         None)
            if order is None:
                raise ValueError(f'Order "{order_id}" nicht in HyperBoost gefunden')
            result = dict(order)

            # ── 2. Test Procedures + Steps ───────────────────────────────
            try:
                rp = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-order/testProcedures",
                           params={"test_order_id": order_id,
                                   "include_steps": True,
                                   "include_instructions": True,
                                   "exclude_actions": True},
                           headers=_hb_auth_headers())
                rp.raise_for_status()
                raw_procs_resp = rp.json()
                raw_procs = raw_procs_resp if isinstance(raw_procs_resp, list) else (
                    raw_procs_resp.get('procedures') or raw_procs_resp.get('testProcedures')
                    or raw_procs_resp.get('items') or raw_procs_resp.get('results') or []
                )
                procedures = []
                for p in raw_procs:
                    raw_steps = p.get('testSteps') or p.get('steps') or []
                    steps = [{
                        'id':          s.get('id', ''),
                        'title':       s.get('title') or s.get('name') or '',
                        'type':        (s.get('testStepType') or {}).get('name')
                                       if isinstance(s.get('testStepType'), dict)
                                       else (s.get('testStepType') or s.get('stepType') or '6.0 Standard Procedures'),
                        'description': s.get('description') or '',
                    } for s in raw_steps]
                    procedures.append({
                        'id':                  p.get('id', ''),
                        'name':                p.get('name') or p.get('identifier') or '',
                        'title':               p.get('title') or p.get('name') or '',
                        'procedure_type_name': (p.get('testProcedureType') or {}).get('name')
                                               if isinstance(p.get('testProcedureType'), dict)
                                               else (p.get('testProcedureType') or
                                                     p.get('procedure_type_name') or '6.0 STANDARD PROCEDURES'),
                        'description':         p.get('description') or '',
                        'steps':               steps,
                    })
                result['procedures'] = procedures
            except Exception:
                result['procedures'] = []

            # ── 3. Test Info (benötigt ESN + Buildnumber) ────────────────
            esn   = (result.get('esn') or result.get('ESN') or
                     result.get('engine_serial_number') or '').strip()
            build = (result.get('buildnumber') or result.get('build_number') or
                     result.get('BuildNumber') or '').strip()
            if esn and build:
                try:
                    ri = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-info",
                               params={"esn": esn, "buildnumber": build,
                                       "testbed": FLOW_CONFIG["HB_TESTBED"]},
                               headers=_hb_auth_headers())
                    result['test_info'] = ri.json() if ri.status_code != 404 else {}
                except Exception:
                    result['test_info'] = {}
            else:
                result['test_info'] = {}

        # ── 4. Statische Attribute ───────────────────────────────────────
        for line in cfg.get('attributes', '').splitlines():
            line = line.strip()
            if '=' in line:
                k, v = line.split('=', 1)
                result[k.strip()] = v.strip()
        return result

    def _exec_hb_write(self, cfg, inputs):
        """
        Erstellt vollständigen HB Test Order:
          Input 0 — metadata dict  (aus pdf_source oder tardis_query)
          Input 1 — steps dict     (aus template_steps oder custom_steps)
        """
        import httpx

        metadata = inputs.get('0') or {}
        steps_in = inputs.get('1') or {}

        # ── kopfdaten-Subdict flachklopfen (kommt von pdf_source) ──
        if 'kopfdaten' in metadata:
            metadata = {**metadata, **metadata['kopfdaten']}

        # ── Hilfsfunktion: case-insensitiver Key-Lookup ───────────
        def _get(d, *keys):
            for k in keys:
                for dk in d:
                    if str(dk).lower() == k.lower():
                        v = d[dk]
                        if v is not None and str(v).strip() not in ('', 'nan', 'None'):
                            return str(v).strip()
            return ''

        # ── Engine Mapping laden (Konstanten überschreiben PDF-Werte) ─
        mappings = _load_engine_mappings()
        raw_engine = _get(metadata, 'engine_type', 'enginetype', 'engine')
        mapping = detect_engine_mapping(raw_engine, mappings)

        # Konstanten aus Mapping (falls vorhanden), sonst Fallback
        engine_type = (mapping.get('engine_type_const') or '').strip() or raw_engine
        testcell    = (mapping.get('testcell') or '').strip() or FLOW_CONFIG['HB_TESTBED']
        test_type   = (mapping.get('test_type_const') or '').strip() or \
                      _get(metadata, 'test_type', 'testtype') or 'MRO'

        # ── Felder aus Metadata extrahieren ───────────────────────
        esn   = _get(metadata, 'esn', 'serial_number', 'serialnumber', 'ESN')
        build = _get(metadata, 'build', 'build_number', 'buildnumber', 'MTU-WBS', 'wbs')
        build = build.replace('.', '')  # "L.19830" → "L19830"

        # ── Order Name ────────────────────────────────────────────
        order_name = cfg.get('orderName', '').strip()
        try:
            order_name = order_name.format(**{str(k): str(v) for k, v in metadata.items()}) if order_name else ''
        except (KeyError, ValueError):
            pass
        if not order_name:
            order_name = '_'.join(filter(None, [engine_type, esn, build]))

        # ── Duplikat-Check: ESN + WO bereits in HyperBoost? ──────
        on_duplicate = cfg.get('onDuplicate', 'error').strip()  # 'error' | 'skip' | 'force'
        if esn and build and on_duplicate != 'force':
            with httpx.Client(timeout=15) as c:
                rd = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-info",
                           params={"esn": esn, "buildnumber": build,
                                   "testbed": FLOW_CONFIG["HB_TESTBED"]},
                           headers=_hb_auth_headers())
            if rd.status_code != 404:
                existing = rd.json() if rd.is_success else {}
                existing_id = existing.get('test_order_id') or existing.get('id') or '?'
                if on_duplicate == 'skip':
                    return {'test_order_id': existing_id, 'order_name': order_name,
                            'duplicate': True, 'skipped': True}
                raise ValueError(
                    f"Duplikat: ESN={esn} + WO={build} existiert bereits in HyperBoost "
                    f"(Order ID: {existing_id}). Nutze onDuplicate=force zum Überschreiben."
                )

        # ── Schritt 1: Order anlegen ──────────────────────────────
        template_title = steps_in.get('template_title', '').strip()
        order_payload = {k: v for k, v in {
            'title':          order_name,
            'testcell':       testcell,
            'test_type':      test_type,
            'engine_type':    engine_type,
            'esn':            esn,
            'template_title': template_title or None,
        }.items() if v}

        with httpx.Client(timeout=30) as c:
            r = c.post(f"{FLOW_CONFIG['HB_BASE']}/test-order",
                       json=order_payload, headers=_hb_auth_headers())
            if not r.is_success:
                try:
                    detail = r.json()
                except Exception:
                    detail = r.text
                raise ValueError(f"POST /test-order {r.status_code}: {detail}\nPayload: {order_payload}")
            data = r.json()

        order_id = (data.get('TestOrder.test', {}).get('id') or
                    data.get('id') or data.get('test_order_id'))
        if not order_id:
            raise ValueError(f"Konnte test_order_id nicht extrahieren: {data}")

        # ── Schritt 2: Test-Info Attribute setzen ─────────────────
        test_info = {k: v for k, v in {
            'name':            order_name,
            'serial-number':   esn,
            'build-number':    build,
            'test-cell-name':  testcell,
            'engineer-name':   cfg.get('engineerName', '').strip(),
            'operator-name-1': cfg.get('operatorName', '').strip(),
            'operator-name-2': cfg.get('operatorName2', '').strip(),
            'spte-name':       cfg.get('spteName', '').strip(),
            'uut-name':        cfg.get('uutName', '').strip(),
            'standard-name':   cfg.get('standardName', '').strip(),
            'config-name':     cfg.get('configName', '').strip(),
            'test-description':cfg.get('testDescription', '').strip(),
        }.items() if v}

        # Konstanten aus Engine Mapping in test_info mergen
        for cm in (mapping.get('const_mappings') or [] if mapping else []):
            attr = cm.get('hb_attr', '').strip()
            val  = cm.get('value', '').strip()
            if attr and val and attr not in test_info:
                test_info[attr] = val

        if test_info:
            with httpx.Client(timeout=30) as c:
                r = c.patch(f"{FLOW_CONFIG['HB_BASE']}/test-order/custom-attributes",
                            params={'test_order_id': order_id},
                            json=test_info, headers=_hb_auth_headers())
                r.raise_for_status()

        # ── Schritt 3: TIP Parameter ──────────────────────────────
        # field_labels[].field IS der HB-Attribut-Key (Single Source of Truth)
        tip_params = {}
        if mapping:
            for fl in (mapping.get('pdf_config', {}).get('field_labels') or []):
                hb_key = fl.get('field', '').strip()
                if hb_key:
                    val = _get(metadata, hb_key)
                    if val:
                        tip_params[hb_key] = val

        # tipOverrides überschreiben Mapping-Werte (KEY=VALUE pro Zeile)
        for line in cfg.get('tipOverrides', '').splitlines():
            line = line.strip()
            if '=' in line and not line.startswith('#'):
                k, _, v = line.partition('=')
                if k.strip():
                    tip_params[k.strip()] = v.strip()

        if tip_params:
            with httpx.Client(timeout=30) as c:
                r = c.patch(f"{FLOW_CONFIG['HB_BASE']}/test-order/custom-attributes",
                            params={'test_order_id': order_id},
                            json=tip_params, headers=_hb_auth_headers())
                r.raise_for_status()

        # ── Schritt 3b: Cluster-Attribute aus Engine Mapping ──────────
        cluster_data   = metadata.get('cluster', {})
        cluster_params = {}
        if mapping:
            for cm in (mapping.get('cluster_mappings') or []):
                ck  = cm.get('cluster_key', '').strip()
                hba = cm.get('hb_attr', '').strip()
                if ck and hba and ck in cluster_data:
                    val = str(cluster_data[ck]).strip()
                    if val and val not in ('–', 'nan', 'None'):
                        cluster_params[hba] = val
        if cluster_params:
            with httpx.Client(timeout=30) as c:
                r = c.patch(f"{FLOW_CONFIG['HB_BASE']}/test-order/custom-attributes",
                            params={'test_order_id': order_id},
                            json=cluster_params, headers=_hb_auth_headers())
                r.raise_for_status()

        # ── Schritt 4: Procedures + Steps anlegen (nur Custom/Dashboard) ──
        # Bei Boost-Templates übernimmt die API das automatisch via template_title
        procs_created = 0
        steps_created = 0

        if not template_title:
            procedures = steps_in.get('procedures', [])
            with httpx.Client(timeout=60) as c:
                for pi, proc in enumerate(procedures):
                    rp = c.post(f"{FLOW_CONFIG['HB_BASE']}/test-procedure",
                                json={k: v for k, v in {
                                    'test_id':                  order_id,
                                    'name':                     proc.get('name', f'PROC_{pi+1}'),
                                    'title':                    proc.get('title') or proc.get('name', ''),
                                    'display_order':            pi + 1,
                                    'description':              proc.get('description', ''),
                                    'test_procedure_type_name': proc.get('procedure_type_name', ''),
                                }.items() if v is not None},
                                headers=_hb_auth_headers())
                    rp.raise_for_status()
                    proc_data = rp.json()
                    proc_id   = proc_data.get('id') or proc_data.get('procedure_id')
                    if not proc_id:
                        continue
                    procs_created += 1

                    for si, step in enumerate(proc.get('steps', [])):
                        rs = c.post(f"{FLOW_CONFIG['HB_BASE']}/test-step",
                                    json={k: v for k, v in {
                                        'test_procedure_id':   proc_id,
                                        'title':               step.get('title', ''),
                                        'test_step_type_name': step.get('type', '6.0 Standard Procedures'),
                                        'display_order':       si + 1,
                                        'description':         step.get('description', ''),
                                    }.items() if v is not None},
                                    headers=_hb_auth_headers())
                        rs.raise_for_status()
                        steps_created += 1

        return {
            'test_order_id':      order_id,
            'order_name':         order_name,
            'template_title':     template_title or None,
            'procedures_created': procs_created,
            'steps_created':      steps_created,
            'tip_params_set':     len(tip_params),
        }

    def _exec_hb_patch(self, cfg, inputs):
        """
        Setzt einzelne Custom Attributes auf einer bestehenden HB Test Order.
          Input 0 — order_id (string oder dict mit 'test_order_id') — optional
          Fallback: orderId aus Config (hb-order-select Dropdown)
        """
        import httpx

        raw = inputs.get('0') or {}
        if isinstance(raw, dict):
            order_id = str(raw.get('test_order_id') or raw.get('order_id') or '').strip()
        else:
            order_id = str(raw).strip()
        if not order_id:
            order_id = cfg.get('orderId', '').strip()
        if not order_id:
            raise ValueError('hb_patch: keine order_id — Input 0 verbinden oder Order im Dropdown wählen')

        attrs = {}
        for line in cfg.get('attributes', '').splitlines():
            line = line.strip()
            if '=' in line and not line.startswith('#'):
                k, _, v = line.partition('=')
                if k.strip():
                    attrs[k.strip()] = v.strip()
        if not attrs:
            raise ValueError('hb_patch: keine Attribute konfiguriert (KEY=VALUE, eine pro Zeile)')

        with httpx.Client(timeout=30) as c:
            r = c.patch(f"{FLOW_CONFIG['HB_BASE']}/test-order/custom-attributes",
                        params={'test_order_id': order_id},
                        json=attrs, headers=_hb_auth_headers())
            r.raise_for_status()

        return {'test_order_id': order_id, 'patched': list(attrs.keys()), 'count': len(attrs)}

    def _exec_template_steps(self, cfg, inputs):
        """Lädt Prozedurstruktur von der HB-API (Boost-Template) oder Dashboard-Templates."""
        import httpx
        mode        = cfg.get('mode', 'boost').strip()
        template_id = cfg.get('templateId', '').strip()

        if mode == 'dashboard':
            # Lokale Dashboard-Templates (werden in Sprint 3 vollständig integriert)
            try:
                dash_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard_templates.json')
                with open(dash_path, 'r', encoding='utf-8') as f:
                    templates = json.load(f)
                tpl = next((t for t in templates if str(t.get('id','')) == template_id), None)
                if tpl:
                    return {"procedures": tpl.get('procedures', []), "mode": "dashboard", "template_id": template_id}
            except FileNotFoundError:
                pass
            return {"procedures": [], "mode": "dashboard", "template_id": template_id}

        # mode == 'boost' — von HB-API laden (Liste abfragen, nach ID filtern)
        if not template_id:
            raise ValueError("Template ID ist nicht konfiguriert")
        with httpx.Client(timeout=30) as c:
            r = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-templates",
                      params={"testcell": FLOW_CONFIG.get("HB_TESTBED", "")},
                      headers=_hb_auth_headers())
            r.raise_for_status()
            tpl_list = r.json()
        if isinstance(tpl_list, dict):
            tpl_list = tpl_list.get('templates') or tpl_list.get('items') or tpl_list.get('results') or []
        # DEBUG: zeige alle Templates + gesuchten Wert
        debug_entries = [{"id": t.get('id'), "identifier": t.get('identifier'), "title": t.get('title'), "name": t.get('name')} for t in tpl_list]
        tpl = next((t for t in tpl_list if str(t.get('id') or t.get('identifier', '')) == template_id), None)
        if tpl is None:
            raise ValueError(
                f"Template '{template_id}' nicht gefunden.\n"
                f"Verfügbare Templates ({len(debug_entries)}):\n" +
                "\n".join(f"  id={e['id']!r}  identifier={e['identifier']!r}  title={e['title']!r}  name={e['name']!r}" for e in debug_entries)
            )
        procedures = tpl.get('procedures') or tpl.get('testProcedures') or []
        return {"procedures": procedures, "template_title": tpl.get('title', ''),
                "mode": "boost", "template_id": template_id,
                "__debug__": {"searched_id": template_id, "found": True,
                              "template_title": tpl.get('title', ''), "total_templates": len(tpl_list)}}

    def _exec_custom_steps(self, cfg, inputs):
        """Lädt Step-Template aus step_templates.json per templateId."""
        template_id = cfg.get('templateId', '').strip()
        if not template_id:
            return {"procedures": [], "mode": "custom"}
        templates = _load_step_templates()
        tpl = next((t for t in templates if t.get('id') == template_id), None)
        if not tpl:
            raise ValueError(f"Step Template '{template_id}' nicht gefunden")
        return {"procedures": tpl.get('procedures', []), "mode": "custom"}

    def _exec_cluster_check(self, cfg, inputs):
        """Prüft Cluster-Parameter aus pdf_source gegen konfigurierte Regeln."""
        metadata     = inputs.get('0') or {}
        cluster_data = metadata.get('cluster', {})
        rules_raw    = cfg.get('rules', '').strip().splitlines()
        results      = []
        for line in rules_raw:
            line = line.strip()
            if not line or line.startswith('#'):
                continue
            # Format: "Cluster Key | operator | expected"
            # Operators: ==  !=  contains  not_empty  is_empty
            passed = None
            op = actual = expected = ''
            for operator in ('==', '!=', 'contains', 'not_empty', 'is_empty'):
                if f' {operator} ' in line or line.endswith(f' {operator}'):
                    parts = line.split(f' {operator} ', 1)
                    key   = parts[0].strip()
                    expected = parts[1].strip() if len(parts) > 1 else ''
                    actual = str(cluster_data.get(key, '')).strip()
                    op = operator
                    if operator == '==':
                        passed = actual.lower() == expected.lower()
                    elif operator == '!=':
                        passed = actual.lower() != expected.lower()
                    elif operator == 'contains':
                        passed = expected.lower() in actual.lower()
                    elif operator == 'not_empty':
                        passed = bool(actual) and actual not in ('–', 'None', 'nan')
                    elif operator == 'is_empty':
                        passed = not actual or actual in ('–', 'None', 'nan')
                    results.append({'rule': line, 'key': key, 'op': op,
                                    'expected': expected, 'actual': actual, 'passed': passed})
                    break
            else:
                results.append({'rule': line, 'key': line, 'op': '?',
                                'expected': '', 'actual': cluster_data.get(line, '—'), 'passed': None})
        passed_n = sum(1 for r in results if r['passed'] is True)
        failed_n = sum(1 for r in results if r['passed'] is False)
        return {'type': 'cluster_check_result', 'results': results,
                'passed': passed_n, 'failed': failed_n, 'total': len(results)}

    def _exec_teams_notify(self, cfg, inputs):
        """Sendet eine Adaptive Card an Microsoft Teams via Power Automate Webhook."""
        import urllib.request, json as _json, ssl
        from datetime import datetime

        data        = inputs.get('0')
        webhook_url = cfg.get('webhookUrl', '').strip()
        if not webhook_url:
            raise ValueError('Webhook URL nicht konfiguriert')

        proxy_url    = cfg.get('proxyUrl', '').strip()
        title_tmpl   = cfg.get('title', '').strip()
        info_tmpl    = cfg.get('infoText', '').strip()
        color        = cfg.get('color', 'blau')
        include_data = cfg.get('includeData', False)

        color_map = {'blau': '0076D7', 'grün': '22c55e', 'rot': 'ef4444', 'gelb': 'f59e0b'}
        accent_color = color_map.get(color, '0076D7')

        # ── Template-Auflösung: Input-Dict flach machen ─────────────────
        def _flatten(d, prefix='', result=None):
            if result is None:
                result = {}
            if isinstance(d, dict):
                for k, v in d.items():
                    _flatten(v, f'{prefix}{k}_' if prefix else f'{k}_', result)
                    result[f'{prefix}{k}' if prefix else k] = str(v)
            return result

        flat = _flatten(data) if isinstance(data, dict) else {}
        flat.setdefault('zeitstempel', datetime.now().strftime('%d.%m.%Y %H:%M:%S'))

        def _resolve(tmpl):
            try:
                return tmpl.format(**flat)
            except KeyError:
                return tmpl  # unbekannte Platzhalter unverändert lassen

        title    = _resolve(title_tmpl) if title_tmpl else 'Teams Benachrichtigung'
        info_str = _resolve(info_tmpl)  if info_tmpl  else ''

        # ── Adaptive Card aufbauen ───────────────────────────────────────
        card_body = [
            {
                'type': 'TextBlock',
                'text': title,
                'weight': 'Bolder',
                'size': 'ExtraLarge',
                'color': 'Accent',
                'wrap': True,
            }
        ]

        if info_str:
            card_body.append({'type': 'TextBlock', 'text': info_str, 'wrap': True})

        # Auto-FactSet: Input-Keys als Key/Value wenn kein infoText
        if not info_str and isinstance(data, dict):
            simple_facts = [
                {'title': str(k) + ':', 'value': str(v)}
                for k, v in data.items()
                if not isinstance(v, (dict, list)) and not str(k).startswith('_')
            ]
            if simple_facts:
                card_body.append({'type': 'FactSet', 'facts': simple_facts[:10]})

        # ── includeData: detaillierte Violations ────────────────────────
        if include_data and isinstance(data, dict):
            dtype = data.get('type', '')

            # FALL A: scan_check_result — pro Scan-Code eine Zeile
            if dtype == 'scan_check_result':
                results = data.get('results', {})
                lines = []
                for code, scan_data in results.items():
                    ok         = scan_data.get('ok', True)
                    scan_nr    = scan_data.get('scan_nr', '?')
                    violations = scan_data.get('violations', [])
                    status_sym = '✅' if ok else '❌'
                    viol_str   = ' | '.join(violations) if violations else '—'
                    lines.append(f'{status_sym} {code} (Scan {scan_nr}): {viol_str}')
                if lines:
                    card_body.append({
                        'type': 'Container',
                        'style': 'emphasis',
                        'items': [{
                            'type': 'TextBlock',
                            'text': '\n'.join(lines),
                            'wrap': True,
                            'fontType': 'Monospace',
                            'size': 'Small',
                        }]
                    })

            # FALL B: row-check — Zusammenfassung + erste Violations
            elif isinstance(data.get('columns'), list) and '__status__' in (data.get('columns') or []):
                cols = data['columns']
                rows = data.get('rows', [])
                status_idx = cols.index('__status__')
                viol_idx   = cols.index('__violations__')
                pass_n = sum(1 for r in rows if r[status_idx] == 'PASS')
                fail_n = sum(1 for r in rows if r[status_idx] == 'FAIL')
                summary = f'PASS: {pass_n}  |  FAIL: {fail_n}  |  Gesamt: {len(rows)}'
                fail_lines = [
                    r[viol_idx] for r in rows
                    if r[status_idx] == 'FAIL' and r[viol_idx]
                ][:5]
                detail = summary
                if fail_lines:
                    detail += '\n' + '\n'.join(fail_lines)
                card_body.append({
                    'type': 'Container',
                    'style': 'emphasis',
                    'items': [{
                        'type': 'TextBlock',
                        'text': detail,
                        'wrap': True,
                        'fontType': 'Monospace',
                        'size': 'Small',
                    }]
                })

            # FALL C: generisches Dict — JSON Code-Block
            else:
                card_body.append({
                    'type': 'Container',
                    'style': 'emphasis',
                    'items': [{
                        'type': 'TextBlock',
                        'text': _json.dumps(data, ensure_ascii=False, indent=2)[:1500],
                        'wrap': True,
                        'fontType': 'Monospace',
                        'size': 'Small',
                    }]
                })

        payload = {
            'type': 'message',
            'attachments': [{
                'contentType': 'application/vnd.microsoft.card.adaptive',
                'content': {
                    'type': 'AdaptiveCard',
                    'version': '1.4',
                    'body': card_body,
                    '$schema': 'http://adaptivecards.io/schemas/adaptive-card.json',
                    'msTeams': {'width': 'Full'},
                }
            }]
        }

        jsondata = _json.dumps(payload).encode('utf-8')

        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode    = ssl.CERT_NONE

        handlers = [urllib.request.HTTPSHandler(context=ctx)]
        if proxy_url:
            handlers.insert(0, urllib.request.ProxyHandler({'http': proxy_url, 'https': proxy_url}))
        opener = urllib.request.build_opener(*handlers)

        req = urllib.request.Request(
            webhook_url,
            data=jsondata,
            headers={'Content-Type': 'application/json'},
        )
        with opener.open(req, timeout=15) as resp:
            status_code = resp.getcode()

        return {'sent': True, 'status_code': status_code, 'title': title}


# ══════════════════════════════════════════════════════════════════
#  CIPA CERT — Helper functions (duplicated from Aerotest.py)
# ══════════════════════════════════════════════════════════════════

def _cipa_get_pin_states(trim_level, engine_config, pmux_option, pin_config):
    """Builds a {pin_nr_str: 'pulled'|'pushed'} dict from the three config parameters."""
    states = {}
    tl = pin_config.get('trim_levels', {})
    ec = pin_config.get('engine_configurations', {})
    po = pin_config.get('pmux_options', {})
    if trim_level is not None:
        states.update(tl.get(str(trim_level), {}))
    if engine_config:
        states.update(ec.get(str(engine_config), {}))
    if pmux_option:
        states.update(po.get(str(pmux_option), {}))
    return states


def _cipa_generate_plot(pin_states):
    """Renders the Pull/Push pin circle diagram; returns PNG bytes."""
    import matplotlib
    matplotlib.use('Agg')
    import matplotlib.pyplot as plt
    from matplotlib.lines import Line2D

    pins_positions = {
        46: (-51, 11), 54: (-78, -6), 47: (-51, -26), 36: (-16, 59),
        37: (-18, 26), 31: (8, -79),  30: (10, -45),  22: (41, -63),
        21: (42, -29), 20: (43, 5),   28: (15, 25),   19: (47, 39),
        27: (17, 57)
    }
    radius = 130

    fig, ax = plt.subplots(figsize=(8.4, 6.0), dpi=100)
    ax.add_artist(plt.Circle((0, 0), radius, color='black', fill=False, linewidth=3))

    for pin_str, status in (pin_states or {}).items():
        number = int(pin_str)
        x, y   = pins_positions.get(number, (0, 0))
        if status == 'pulled':
            ax.add_artist(plt.Circle((x, y), 12, edgecolor='black', facecolor='black', linewidth=2))
            ax.text(x, y, str(number), color='white', fontsize=10, ha='center', va='center')
        else:
            ax.add_artist(plt.Circle((x, y), 12, edgecolor='black', facecolor='none', linewidth=2))
            ax.text(x, y, str(number), color='black', fontsize=10, ha='center', va='center')

    ax.legend(handles=[
        Line2D([0], [0], marker='o', color='w', markerfacecolor='black',
               markeredgecolor='black', markersize=10, label='Pull'),
        Line2D([0], [0], marker='o', color='w', markeredgecolor='black',
               markerfacecolor='none',  markersize=10, label='Push'),
    ], loc='upper right')

    ax.set_aspect('equal')
    ax.set_xlim(-radius * 1.2, radius * 1.2)
    ax.set_ylim(-radius * 1.2, radius * 1.2)
    ax.axis('off')

    buf = BytesIO()
    fig.savefig(buf, format='png', bbox_inches='tight', pad_inches=0.1)
    plt.close(fig)
    buf.seek(0)
    return buf.read()


def _fill_docx_with_images(template_path, text_replacements, image_placeholders):
    """
    Fills a DOCX template with text replacements AND inline images.
    image_placeholders: {placeholder_str: png_bytes}
    Returns filled DOCX as bytes.
    """
    import zipfile as zf

    with zf.ZipFile(template_path, 'r') as zin:
        names = zin.namelist()
        file_data = {n: zin.read(n) for n in names}

    # Add image files and register relationships
    rels_xml      = file_data.get('word/_rels/document.xml.rels', b'')
    rels_text     = rels_xml.decode('utf-8', errors='replace')
    doc_xml_bytes = file_data.get('word/document.xml', b'')
    doc_text      = doc_xml_bytes.decode('utf-8', errors='replace')

    # Find highest existing rId number to avoid collisions
    existing_ids  = [int(m) for m in _re.findall(r'Id="rId(\d+)"', rels_text)]
    next_rid      = (max(existing_ids) + 1) if existing_ids else 100

    img_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'

    for ph, png_bytes in image_placeholders.items():
        if ph not in doc_text:
            continue
        rid       = f'rId{next_rid}'
        img_name  = f'image_cipa_{next_rid}.png'
        media_key = f'word/media/{img_name}'
        next_rid += 1

        # Store image in ZIP
        file_data[media_key] = png_bytes

        # Add relationship entry (insert before </Relationships>)
        rel_entry = (
            f'<Relationship Id="{rid}" '
            f'Type="{img_ns}" '
            f'Target="media/{img_name}"/>'
        )
        rels_text = rels_text.replace('</Relationships>', rel_entry + '</Relationships>')

        # Build inline drawing XML (2.4 inches = 2194560 EMU)
        emu       = 2194560
        draw_xml  = (
            f'<w:drawing xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">'
            f'<wp:inline distT="0" distB="0" distL="0" distR="0">'
            f'<wp:extent cx="{emu}" cy="{int(emu * 0.75)}"/>'
            f'<wp:effectExtent l="0" t="0" r="0" b="0"/>'
            f'<wp:docPr id="{next_rid}" name="{img_name}"/>'
            f'<wp:cNvGraphicFramePr>'
            f'<a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/>'
            f'</wp:cNvGraphicFramePr>'
            f'<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
            f'<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
            f'<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">'
            f'<pic:nvPicPr><pic:cNvPr id="{next_rid}" name="{img_name}"/><pic:cNvPicPr/></pic:nvPicPr>'
            f'<pic:blipFill>'
            f'<a:blip r:embed="{rid}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>'
            f'<a:stretch><a:fillRect/></a:stretch>'
            f'</pic:blipFill>'
            f'<pic:spPr>'
            f'<a:xfrm><a:off x="0" y="0"/><a:ext cx="{emu}" cy="{int(emu * 0.75)}"/></a:xfrm>'
            f'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
            f'</pic:spPr>'
            f'</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing>'
        )

        # Replace placeholder paragraph with image run
        # Find the <w:p> containing the placeholder and replace its content
        def _replace_ph_para(m, _ph=ph, _draw=draw_xml):
            para = m.group(0)
            combined = ''.join(_re.findall(r'<w:t[^>]*>([^<]*)</w:t>', para))
            if _ph not in combined:
                return para
            # Keep paragraph properties, replace runs with image
            rpr_m = _re.search(r'<w:rPr>.*?</w:rPr>', para, _re.DOTALL)
            rpr   = rpr_m.group(0) if rpr_m else ''
            ppr_m = _re.search(r'<w:pPr>.*?</w:pPr>', para, _re.DOTALL)
            ppr   = ppr_m.group(0) if ppr_m else ''
            return f'<w:p>{ppr}<w:r>{rpr}{_draw}</w:r></w:p>'

        doc_text = _re.sub(r'<w:p[ >].*?</w:p>', _replace_ph_para, doc_text, flags=_re.DOTALL)

    # Apply text replacements
    file_data['word/document.xml']               = _replace_in_docx_xml(doc_text.encode('utf-8'), text_replacements)
    file_data['word/_rels/document.xml.rels']    = rels_text.encode('utf-8')

    buf = BytesIO()
    with zf.ZipFile(buf, 'w', zf.ZIP_DEFLATED) as zout:
        for name, data in file_data.items():
            zout.writestr(name, data)
    buf.seek(0)
    return buf.read()


import os, re as _re

FLOWS_DIR      = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'flows')
FLOW_CATS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'settings', 'flow_config_categories.json')
os.makedirs(FLOWS_DIR, exist_ok=True)
os.makedirs(os.path.dirname(FLOW_CATS_FILE), exist_ok=True)


@app.route('/api/flow/save', methods=['POST'])
def flow_save():
    import uuid as _uuid
    from datetime import datetime
    body = request.get_json(force=True)
    name = body.get('name', '').strip()
    if not name:
        return jsonify({'error': 'Name fehlt'}), 400
    safe = _re.sub(r'[^a-zA-Z0-9_\-]', '_', name)
    path = os.path.join(FLOWS_DIR, safe + '.json')
    # preserve UUID if file already exists, else use client UUID or generate new
    existing_uuid = None
    if os.path.exists(path):
        try:
            with open(path, encoding='utf-8') as ef:
                existing_uuid = json.load(ef).get('uuid')
        except Exception:
            pass
    flow_uuid = existing_uuid or body.get('uuid') or str(_uuid.uuid4())
    with open(path, 'w', encoding='utf-8') as f:
        json.dump({
            'uuid':        flow_uuid,
            'name':        name,
            'saved_at':    datetime.now().isoformat(timespec='seconds'),
            'saved_by':    FLOW_CONFIG['TARDIS_USER'],
            'category':    body.get('category') or None,
            'tags':        [t.strip() for t in body.get('tags', []) if str(t).strip()],
            'description': body.get('description', '').strip(),
            'nodes':       body.get('nodes', []),
            'connections': body.get('connections', []),
        }, f, indent=2)
    return jsonify({'ok': True, 'file': safe + '.json', 'uuid': flow_uuid})


@app.route('/api/flow/list', methods=['GET'])
def flow_list():
    result = []
    for fn in sorted(os.listdir(FLOWS_DIR)):
        if not fn.endswith('.json'):
            continue
        file_key = fn[:-5]
        try:
            with open(os.path.join(FLOWS_DIR, fn), encoding='utf-8') as fh:
                data = json.load(fh)
            result.append({
                'file':        file_key,
                'uuid':        data.get('uuid', ''),
                'name':        data.get('name', file_key),
                'saved_at':    data.get('saved_at', ''),
                'saved_by':    data.get('saved_by', ''),
                'category':    data.get('category') or None,
                'tags':        data.get('tags', []),
                'description': data.get('description', ''),
            })
        except Exception:
            result.append({'file': file_key, 'name': file_key,
                           'saved_at': '', 'saved_by': '', 'tags': [], 'description': ''})
    result.sort(key=lambda x: x['saved_at'], reverse=True)
    return jsonify(result)


@app.route('/api/flow/load/<name>', methods=['GET'])
def flow_load(name):
    safe = _re.sub(r'[^a-zA-Z0-9_\-]', '_', name)
    path = os.path.join(FLOWS_DIR, safe + '.json')
    if not os.path.exists(path):
        return jsonify({'error': 'Nicht gefunden'}), 404
    with open(path, encoding='utf-8') as f:
        return jsonify(json.load(f))


@app.route('/api/flow/delete/<name>', methods=['DELETE'])
def flow_delete(name):
    safe = _re.sub(r'[^a-zA-Z0-9_\-]', '_', name)
    path = os.path.join(FLOWS_DIR, safe + '.json')
    if os.path.exists(path):
        os.remove(path)
    return jsonify({'ok': True})


@app.route('/api/flow/rename', methods=['POST'])
def flow_rename():
    body     = request.get_json(force=True)
    old_file = _re.sub(r'[^a-zA-Z0-9_\-]', '_', body.get('old_file', ''))
    new_name = body.get('new_name', '').strip()
    new_file = _re.sub(r'[^a-zA-Z0-9_\-]', '_', new_name)
    old_path = os.path.join(FLOWS_DIR, old_file + '.json')
    new_path = os.path.join(FLOWS_DIR, new_file + '.json')
    if not os.path.exists(old_path):
        return jsonify({'error': 'Nicht gefunden'}), 404
    with open(old_path, encoding='utf-8') as f:
        data = json.load(f)
    data['name'] = new_name
    with open(new_path, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2)
    if old_path != new_path:
        os.remove(old_path)
    return jsonify({'ok': True, 'file': new_file})


@app.route('/api/flow/meta/<name>', methods=['PATCH'])
def flow_meta_patch(name):
    """Update name, tags, description of an existing flow without touching nodes/connections."""
    safe = _re.sub(r'[^a-zA-Z0-9_\-]', '_', name)
    path = os.path.join(FLOWS_DIR, safe + '.json')
    if not os.path.exists(path):
        return jsonify({'error': 'Nicht gefunden'}), 404
    body = request.get_json(force=True)
    with open(path, encoding='utf-8') as f:
        data = json.load(f)
    new_name = body.get('name', data.get('name', '')).strip()
    new_file = _re.sub(r'[^a-zA-Z0-9_\-]', '_', new_name)
    if 'name'        in body: data['name']        = new_name
    if 'category'    in body: data['category']    = body['category'] or None
    if 'tags'        in body: data['tags']         = body['tags']
    if 'description' in body: data['description']  = body['description'].strip()
    new_path = os.path.join(FLOWS_DIR, new_file + '.json')
    with open(new_path, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2)
    if path != new_path:
        os.remove(path)
    return jsonify({'ok': True, 'file': new_file})


_FLOW_PRESET_CATS = [
    'CFM56-7B','CFM56-5B','V2500','PW1100G','GP7200',
    'MRO','Zertifikat','Entwicklung','Test','Debug',
]

def _load_categories():
    """Return list of {uuid, name}. Seeds file with presets on first run."""
    import uuid as _uuid
    if os.path.exists(FLOW_CATS_FILE):
        try:
            with open(FLOW_CATS_FILE, encoding='utf-8') as f:
                data = json.load(f)
            if 'categories' in data:
                return data['categories']
        except Exception:
            pass
    # Seed defaults
    cats = [{'uuid': str(_uuid.uuid4()), 'name': n} for n in _FLOW_PRESET_CATS]
    with open(FLOW_CATS_FILE, 'w', encoding='utf-8') as f:
        json.dump({'categories': cats}, f, indent=2)
    return cats


@app.route('/api/flow/categories', methods=['GET'])
def flow_categories_get():
    return jsonify({'categories': _load_categories()})


@app.route('/api/flow/categories', methods=['POST'])
def flow_categories_save():
    """Add a new category."""
    import uuid as _uuid
    body = request.get_json(force=True)
    name = body.get('name', '').strip()
    if not name:
        return jsonify({'error': 'Name fehlt'}), 400
    cats = _load_categories()
    if any(c['name'] == name for c in cats):
        return jsonify({'error': 'Bereits vorhanden'}), 409
    new_cat = {'uuid': str(_uuid.uuid4()), 'name': name}
    cats.append(new_cat)
    with open(FLOW_CATS_FILE, 'w', encoding='utf-8') as f:
        json.dump({'categories': cats}, f, indent=2)
    return jsonify({'ok': True, 'category': new_cat})


@app.route('/api/flow/categories/<cat_uuid>', methods=['PATCH'])
def flow_categories_rename(cat_uuid):
    """Rename a category by UUID and update all flow files."""
    body     = request.get_json(force=True)
    new_name = body.get('name', '').strip()
    if not new_name:
        return jsonify({'error': 'Name fehlt'}), 400
    cats = _load_categories()
    old_name = None
    for c in cats:
        if c['uuid'] == cat_uuid:
            old_name = c['name']
            c['name'] = new_name
            break
    if old_name is None:
        return jsonify({'error': 'Nicht gefunden'}), 404
    with open(FLOW_CATS_FILE, 'w', encoding='utf-8') as f:
        json.dump({'categories': cats}, f, indent=2)
    # Flows store category UUIDs — no flow files need updating on rename
    return jsonify({'ok': True})


@app.route('/api/flow/categories/<cat_uuid>', methods=['DELETE'])
def flow_categories_delete(cat_uuid):
    """Delete a category by UUID and remove it from all flow files."""
    cats = _load_categories()
    cats = [c for c in cats if c['uuid'] != cat_uuid]
    with open(FLOW_CATS_FILE, 'w', encoding='utf-8') as f:
        json.dump({'categories': cats}, f, indent=2)
    for fn in os.listdir(FLOWS_DIR):
        if not fn.endswith('.json'):
            continue
        fp = os.path.join(FLOWS_DIR, fn)
        try:
            with open(fp, encoding='utf-8') as f:
                data = json.load(f)
            changed = False
            if data.get('category') == cat_uuid:
                data['category'] = None
                changed = True
            if changed:
                with open(fp, 'w', encoding='utf-8') as f:
                    json.dump(data, f, indent=2)
        except Exception:
            pass
    return jsonify({'ok': True})


@app.route('/api/tardis/projects')
def tardis_projects():
    from mtu.cae.pytardis import messages, filters
    try:
        msgr = _get_messenger()
        qf   = filters.QueryFilter([("Project.Name", "lk", "*")])
        msg  = messages.QueryMessage(query_filter=qf,
               result_parameters={"resultType": "Project",
                                  "columns": ["Project.Name", "Project.Id"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        df = msgr.query(msg).to_dataframe()
        return jsonify([{"name": r["Project.Name"], "id": str(r["Project.Id"])}
                        for _, r in df.iterrows()])
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/tardis/pools')
def tardis_pools():
    from mtu.cae.pytardis import messages, filters
    project = request.args.get('project', '')
    try:
        msgr = _get_messenger()
        qf   = filters.QueryFilter({"Project.Name": project})
        msg  = messages.QueryMessage(query_filter=qf,
               result_parameters={"resultType": "Pool",
                                  "columns": ["Pool.Name", "Pool.Id"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        df = msgr.query(msg).to_dataframe()
        return jsonify([{"name": r["Pool.Name"], "id": str(r["Pool.Id"])}
                        for _, r in df.iterrows()])
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/tardis/tests')
def tardis_tests():
    from mtu.cae.pytardis import messages, filters
    project = request.args.get('project', '')
    pool    = request.args.get('pool', '')
    try:
        msgr = _get_messenger()
        qf   = filters.QueryFilter({"Project.Name": project, "Pool.Name": pool})
        msg  = messages.QueryMessage(query_filter=qf,
               result_parameters={"resultType": "Test",
                                  "columns": ["Test.Name", "Test.Id", "Test.Description"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        df = msgr.query(msg).to_dataframe()
        return jsonify([{"name": r["Test.Name"], "id": str(r["Test.Id"]),
                         "description": str(r.get("Test.Description", ""))}
                        for _, r in df.iterrows()])
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/scantable')
def get_scantable():
    from mtu.cae.pytardis import messages, filters
    test_id = request.args.get('test_id')
    SCAN_CHANNELS = ['_ScanNr', '_Code', '_ScanComment', '_timestamp']
    MEA_NAME = '[measured, measurement absolute]'
    try:
        import pandas as pd
        msgr = _get_messenger()
        qf = filters.QueryFilter({"Test.Id": test_id, "Measurement.Name": MEA_NAME})
        qf.append(("Channel.Name", "in", SCAN_CHANNELS))
        dc = msgr.send(messages.DataReadMessage(qf, source=FLOW_CONFIG["TARDIS_SOURCE"]))
        if len(dc) == 0:
            return jsonify({"error": f"Keine Daten für Measurement '{MEA_NAME}' gefunden"})
        frames = []
        for item in dc:
            df = item.values
            available = [c for c in SCAN_CHANNELS if c in df.columns]
            if available:
                frames.append(df[available])
        if not frames:
            return jsonify({"error": f"Kanäle {SCAN_CHANNELS} nicht gefunden"})
        combined = pd.concat(frames, ignore_index=True).head(1000)
        rows = [[str(v) if v is not None else "" for v in row] for row in combined.values.tolist()]
        return jsonify({"columns": list(combined.columns), "rows": rows,
                        "total_rows": len(combined)})
    except Exception as e:
        return jsonify({"error": str(e)})


@app.route('/api/tardis/find_test')
def tardis_find_test():
    esn             = request.args.get('esn', '').strip()
    wo              = request.args.get('wo', '').strip()
    project_hint    = request.args.get('project', '').strip()   # optional: skip full project scan
    if not esn and not wo:
        return jsonify(None)
    try:
        from mtu.cae.pytardis import messages, filters
        msgr = _get_messenger()
        if project_hint:
            projects = [project_hint]
        else:
            proj_msg = messages.QueryMessage(
                query_filter=filters.QueryFilter([("Project.Name", "lk", "*")]),
                result_parameters={"resultType": "Project", "columns": ["Project.Name"]},
                source=FLOW_CONFIG["TARDIS_SOURCE"])
            proj_df  = msgr.query(proj_msg).to_dataframe()
            projects = [str(r["Project.Name"]) for _, r in proj_df.iterrows()]
            default_p = FLOW_CONFIG['DEFAULT_PROJECT']
            if default_p in projects:
                projects = [default_p] + [p for p in projects if p != default_p]
        for project in projects:
            pool_msg = messages.QueryMessage(
                query_filter=filters.QueryFilter({"Project.Name": project}),
                result_parameters={"resultType": "Pool", "columns": ["Pool.Name"]},
                source=FLOW_CONFIG["TARDIS_SOURCE"])
            pool_df = msgr.query(pool_msg).to_dataframe()
            pools   = [str(r["Pool.Name"]) for _, r in pool_df.iterrows()]
            default_pool = FLOW_CONFIG['DEFAULT_POOL']
            if default_pool in pools:
                pools = [default_pool] + [p for p in pools if p != default_pool]
            for pool in pools:
                qf  = filters.QueryFilter({"Project.Name": project, "Pool.Name": pool})
                msg = messages.QueryMessage(
                    query_filter=qf,
                    result_parameters={"resultType": "Test", "columns": ["Test.Name", "Test.Id"]},
                    source=FLOW_CONFIG["TARDIS_SOURCE"])
                df = msgr.query(msg).to_dataframe()
                for _, row in df.iterrows():
                    name = str(row.get("Test.Name", ""))
                    esn_ok = (not esn) or (esn in name)
                    wo_ok  = (not wo)  or (wo  in name)
                    if esn_ok and wo_ok and (esn or wo):
                        return jsonify({
                            "project": project, "pool": pool,
                            "test_id": str(row["Test.Id"]), "test_name": name
                        })
        return jsonify(None)
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/tardis/resolve_context')
def tardis_resolve_context():
    """Resolves full TARDIS context chain for a given ESN/WO + known project.
    Returns all IDs needed to configure a tardis_query node automatically."""
    esn     = request.args.get('esn', '').strip()
    wo      = request.args.get('wo', '').strip()
    project = request.args.get('project', '').strip()
    pool    = request.args.get('pool', 'engine').strip() or 'engine'
    if not (esn or wo) or not project:
        return jsonify({'error': 'esn/wo und project erforderlich'}), 400
    try:
        from mtu.cae.pytardis import messages, filters
        msgr = _get_messenger()

        # 1. Find test matching ESN/WO in the given project+pool
        qf  = filters.QueryFilter({"Project.Name": project, "Pool.Name": pool})
        msg = messages.QueryMessage(query_filter=qf,
              result_parameters={"resultType": "Test", "columns": ["Test.Name", "Test.Id"]},
              source=FLOW_CONFIG["TARDIS_SOURCE"])
        df = msgr.query(msg).to_dataframe()
        test_row = None
        for _, row in df.iterrows():
            name = str(row.get("Test.Name", ""))
            esn_ok = (not esn) or (esn in name)
            wo_ok  = (not wo)  or (wo  in name)
            if esn_ok and wo_ok and (esn or wo):
                test_row = row; break
        if test_row is None:
            return jsonify({'error': f'Kein Test für ESN={esn}/WO={wo} in {project}/{pool} gefunden'})
        test_id   = str(test_row["Test.Id"])
        test_name = str(test_row["Test.Name"])

        # 2. TestSteps — take first available
        qf2  = filters.QueryFilter({"Test.Id": test_id})
        msg2 = messages.QueryMessage(query_filter=qf2,
               result_parameters={"resultType": "TestStep",
                                  "columns": ["TestStep.Name", "TestStep.Id"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        steps_df = msgr.query(msg2).to_dataframe()
        if steps_df.empty:
            return jsonify({'error': 'Kein TestStep gefunden'})
        step_row  = steps_df.iloc[0]
        step_id   = str(step_row["TestStep.Id"])
        step_name = str(step_row["TestStep.Name"])

        # 3. Measurements — prefer name containing "absolute"
        qf3  = filters.QueryFilter({"TestStep.Id": step_id})
        msg3 = messages.QueryMessage(query_filter=qf3,
               result_parameters={"resultType": "Measurement",
                                  "columns": ["Measurement.Name", "Measurement.Id"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        meas_df = msgr.query(msg3).to_dataframe()
        if meas_df.empty:
            return jsonify({'error': 'Keine Measurement gefunden'})
        meas_row = next(
            (row for _, row in meas_df.iterrows()
             if 'absolute' in str(row.get('Measurement.Name', '')).lower()),
            meas_df.iloc[0])
        meas_id   = str(meas_row["Measurement.Id"])
        meas_name = str(meas_row["Measurement.Name"])

        # 4. ChannelGroups — prefer name containing "steady state"
        qf4  = filters.QueryFilter({"Measurement.Id": meas_id})
        msg4 = messages.QueryMessage(query_filter=qf4,
               result_parameters={"resultType": "ChannelGroup",
                                  "columns": ["ChannelGroup.Name", "ChannelGroup.Id"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        cg_df = msgr.query(msg4).to_dataframe()
        if cg_df.empty:
            return jsonify({'error': 'Keine ChannelGroup gefunden'})
        cg_row = next(
            (row for _, row in cg_df.iterrows()
             if 'steady' in str(row.get('ChannelGroup.Name', '')).lower()),
            cg_df.iloc[0])
        cg_id   = str(cg_row["ChannelGroup.Id"])
        cg_name = str(cg_row["ChannelGroup.Name"])

        return jsonify({
            'th_project':        project,
            'th_pool':           pool,
            'th_test_id':        test_id,
            'th_teststep_id':    step_id,
            'th_measurement_id': meas_id,
            'channelgroupId':    cg_id,
            'test_name':         test_name,
            'step_name':         step_name,
            'meas_name':         meas_name,
            'cg_name':           cg_name,
        })
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/api/tardis/teststeps')
def tardis_teststeps():
    from mtu.cae.pytardis import messages, filters
    test_id = request.args.get('test_id', '')
    try:
        msgr = _get_messenger()
        qf   = filters.QueryFilter({"Test.Id": test_id})
        msg  = messages.QueryMessage(query_filter=qf,
               result_parameters={"resultType": "TestStep",
                                  "columns": ["TestStep.Name", "TestStep.Id"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        df = msgr.query(msg).to_dataframe()
        return jsonify([{"name": r["TestStep.Name"], "id": str(r["TestStep.Id"])}
                        for _, r in df.iterrows()])
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/tardis/measurements')
def tardis_measurements():
    from mtu.cae.pytardis import messages, filters
    teststep_id = request.args.get('teststep_id', '')
    try:
        msgr = _get_messenger()
        qf   = filters.QueryFilter({"TestStep.Id": teststep_id})
        msg  = messages.QueryMessage(query_filter=qf,
               result_parameters={"resultType": "Measurement",
                                  "columns": ["Measurement.Name", "Measurement.Id",
                                              "Measurement.MeasurementBegin",
                                              "Measurement.MeasurementEnd"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        df = msgr.query(msg).to_dataframe()
        return jsonify([{"name": r["Measurement.Name"], "id": str(r["Measurement.Id"]),
                         "begin": str(r.get("Measurement.MeasurementBegin", "")),
                         "end":   str(r.get("Measurement.MeasurementEnd",   ""))}
                        for _, r in df.iterrows()])
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/tardis/channelgroups')
def tardis_channelgroups():
    from mtu.cae.pytardis import messages, filters
    measurement_id = request.args.get('measurement_id', '')
    try:
        msgr = _get_messenger()
        qf   = filters.QueryFilter({"Measurement.Id": measurement_id})
        msg  = messages.QueryMessage(query_filter=qf,
               result_parameters={"resultType": "ChannelGroup",
                                  "columns": ["ChannelGroup.Name", "ChannelGroup.Id"]},
               source=FLOW_CONFIG["TARDIS_SOURCE"])
        df = msgr.query(msg).to_dataframe()
        return jsonify([{"name": r["ChannelGroup.Name"], "id": str(r["ChannelGroup.Id"])}
                        for _, r in df.iterrows()])
    except Exception as e:
        return jsonify({"error": str(e)}), 500


# ── Process Flow ─────────────────────────────────────────────────

_PROCESS_INSTANCES_DIR  = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'process_instances')
_PROCESS_TEMPLATES_DIR  = os.path.join(_PROCESS_INSTANCES_DIR, 'templates')
_PROCESS_TEMPLATES_FILE = os.path.join(_PROCESS_TEMPLATES_DIR, 'process_templates.json')
_ADDRESS_BOOK_FILE      = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'settings', 'address_book.json')
os.makedirs(_PROCESS_INSTANCES_DIR, exist_ok=True)
os.makedirs(_PROCESS_TEMPLATES_DIR, exist_ok=True)

def _load_address_book():
    if os.path.exists(_ADDRESS_BOOK_FILE):
        with open(_ADDRESS_BOOK_FILE, encoding='utf-8') as f:
            return json.load(f)
    return {"engineers": []}

def _save_address_book(data):
    with open(_ADDRESS_BOOK_FILE, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)

def _load_process_templates():
    if os.path.exists(_PROCESS_TEMPLATES_FILE):
        with open(_PROCESS_TEMPLATES_FILE, encoding='utf-8') as f:
            return json.load(f)
    return []

def _save_process_templates(data):
    with open(_PROCESS_TEMPLATES_FILE, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)

def _instance_filename(esn, wo):
    safe = lambda s: _re.sub(r'[^a-zA-Z0-9_\-]', '_', str(s).strip())
    return safe(esn) + '__' + safe(wo) + '.json'

def _load_instance(esn, wo):
    path = os.path.join(_PROCESS_INSTANCES_DIR, _instance_filename(esn, wo))
    if os.path.exists(path):
        with open(path, encoding='utf-8') as f:
            return json.load(f)
    return None

def _save_instance(data):
    esn = data.get('esn', '')
    wo  = data.get('wo', '')
    path = os.path.join(_PROCESS_INSTANCES_DIR, _instance_filename(esn, wo))
    with open(path, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)

# ── Process Template Endpoints ────────────────────────────────────

@app.route('/api/process/templates', methods=['GET'])
def get_process_templates():
    return jsonify(_load_process_templates())

@app.route('/api/process/templates', methods=['POST'])
def post_process_templates():
    try:
        _save_process_templates(request.get_json(force=True))
        return jsonify({'ok': True})
    except Exception as e:
        return jsonify({'error': str(e)}), 500

# ── Address Book Endpoints ────────────────────────────────────────

@app.route('/api/address_book', methods=['GET'])
def get_address_book():
    return jsonify(_load_address_book())

@app.route('/api/address_book', methods=['POST'])
def post_address_book():
    try:
        _save_address_book(request.get_json(force=True))
        return jsonify({'ok': True})
    except Exception as e:
        return jsonify({'error': str(e)}), 500

# ── Process Instance Endpoints ────────────────────────────────────

@app.route('/api/process/instances', methods=['GET'])
def list_process_instances():
    instances = []
    for fn in sorted(os.listdir(_PROCESS_INSTANCES_DIR)):
        if not fn.endswith('.json'): continue
        path = os.path.join(_PROCESS_INSTANCES_DIR, fn)
        try:
            with open(path, encoding='utf-8') as f:
                d = json.load(f)
            instances.append({
                'file':           fn[:-5],
                'esn':            d.get('esn', ''),
                'wo':             d.get('wo', ''),
                'template':       d.get('template', ''),
                'label':          d.get('label', ''),
                'kb_status':      d.get('kb_status', 'planned'),
                'kb_engine_type': d.get('kb_engine_type', d.get('template', '')),
                'sap_engine':     d.get('sap_engine', ''),
                'kb_customer':    d.get('kb_customer', ''),
                'kb_engineer':    d.get('kb_engineer', ''),
                'kb_dates':       d.get('kb_dates', {}),
            })
        except Exception:
            pass
    return jsonify(instances)

def _kb_get_template_paths(engine_type_upper):
    """Returns (base_root, wrb_folder, archive_root, mail_templates) from the matching process template."""
    tpls = _load_process_templates()
    tpl  = next((t for t in tpls if t.get('engine_type', '').upper() == engine_type_upper), None)
    paths = tpl.get('paths', {}) if tpl else {}
    base_root      = paths.get('base_root',      '').strip() or None
    wrb_folder     = paths.get('wrb_folder',     '').strip() or r'L:\lgroup\WRB_Revision'
    archive_root   = paths.get('archive_root',   '').strip() or None
    mail_templates = paths.get('mail_templates', '').strip() or None
    return base_root, wrb_folder, archive_root, mail_templates


def _kb_find_wo_folder(base_root, wo):
    wo = wo.replace('.', '')
    try:
        with os.scandir(base_root) as it:
            for e in it:
                if e.is_dir() and wo in e.name:
                    return e.name
    except Exception:
        pass
    return None


def _kb_resolve_folder(engine, esn, wo, path_template):
    """Substitutes {engine}/{esn}/{wo} variables, then resolves via glob if '*' present."""
    import glob as _glob
    if not path_template or path_template in ('#', '#auto'):
        return None
    wo = wo.replace('.', '')
    path = path_template \
        .replace('{engine}', engine.strip()) \
        .replace('{esn}',    esn.strip()).replace('{ESN}', esn.strip()) \
        .replace('{wo}',     wo.strip()).replace('{WO}',  wo.strip())
    if '*' in path:
        matches = sorted(_glob.glob(path))
        return matches[-1] if matches else None
    return path if os.path.exists(path) else None


def _kb_folder_entry(engine, folder_key):
    tpls = _load_process_templates()
    tpl  = next((t for t in tpls if t.get('engine_type','') == engine), None)
    return next((f for f in (tpl or {}).get('folders', []) if f.get('key') == folder_key), None)


@app.route('/api/kb/open_folder/<folder_key>/<engine>/<esn>/<wo>')
def kb_open_folder(folder_key, engine, esn, wo):
    folder = _kb_folder_entry(engine, folder_key)
    if not folder or not folder.get('path'):
        return jsonify({'ok': False, 'error': 'folder not configured'}), 404
    path = _kb_resolve_folder(engine, esn, wo, folder['path'])
    if not path:
        return jsonify({'ok': False, 'error': f'Pfad nicht gefunden für {folder_key}'}), 404
    try:
        if os.name == 'nt':
            os.startfile(path)
        else:
            import subprocess, sys
            subprocess.Popen(['open' if sys.platform == 'darwin' else 'xdg-open', path])
        return jsonify({'ok': True, 'path': path})
    except Exception as e:
        return jsonify({'ok': False, 'error': str(e)}), 500


@app.route('/api/kb/resolve_folder/<folder_key>/<engine>/<esn>/<wo>')
def kb_resolve_folder(folder_key, engine, esn, wo):
    folder = _kb_folder_entry(engine, folder_key)
    if not folder or not folder.get('path'):
        return jsonify({'found': False, 'message': 'Nicht konfiguriert'})
    path = _kb_resolve_folder(engine, esn, wo, folder['path'])
    if path:
        return jsonify({'found': True, 'path': path})
    return jsonify({'found': False, 'message': 'Pfad nicht gefunden'})


def _kb_archive_engine_folder(engine):
    """Maps engine subtype to the physical folder name used in the test archive."""
    if engine.upper().startswith('CF34-8'):
        return 'CF34-8'
    return engine


@app.route('/api/kb/documents', methods=['GET'])
def kb_documents():
    import glob as _glob
    from datetime import datetime as _dt
    wo          = request.args.get('wo', '').strip()
    esn         = request.args.get('esn', '').strip()
    engine_type = request.args.get('engine_type', '').strip().upper()
    if not wo:
        return jsonify({}), 400

    base_root, wrb_folder, archive_root, mail_templates_path = _kb_get_template_paths(engine_type)

    def _file_date(path):
        try:
            return _dt.fromtimestamp(os.path.getmtime(path)).strftime('%Y-%m-%d')
        except Exception:
            return ''

    def _doc(found, folder_exists, file_path):
        if found and file_path:
            return {'status': 'found', 'file_name': os.path.basename(file_path),
                    'file_date': _file_date(file_path), 'file_path': file_path}
        if folder_exists:
            return {'status': 'folder_only', 'file_name': None, 'file_date': None, 'file_path': None}
        return      {'status': 'missing',    'file_name': None, 'file_date': None, 'file_path': None}

    def _wrb():
        if not os.path.exists(wrb_folder):
            return _doc(False, False, None)
        s_wo  = wo[0] + '.' + wo[1:] if len(wo) > 1 and wo[1] != '.' else wo
        files = _glob.glob(os.path.join(wrb_folder, f'*{s_wo}*.pdf'))
        if files:
            return _doc(True, True, max(files, key=os.path.getmtime))
        return _doc(False, True, None)

    def _service_order():
        if not base_root or not os.path.exists(base_root):
            return _doc(False, False, None)
        wo_folder = _kb_find_wo_folder(base_root, wo)
        if not wo_folder:
            return _doc(False, False, None)
        cs   = os.path.join(base_root, wo_folder, 'Customer_Support')
        dirs = [os.path.join(cs, 'Service_Order'), os.path.join(cs, 'Service Order'), cs]
        files = []
        for d in dirs:
            if os.path.exists(d):
                files += [os.path.join(d, f) for f in os.listdir(d)
                          if 'so' in f.lower() and f.lower().endswith('.xlsm') and not f.startswith('~$')]
        if files:
            return _doc(True, True, max(files, key=os.path.getmtime))
        return _doc(False, next((True for d in dirs if os.path.exists(d)), False), None)

    def _slave():
        if not base_root or not os.path.exists(base_root):
            return _doc(False, False, None)
        wo_folder = _kb_find_wo_folder(base_root, wo)
        if not wo_folder:
            return _doc(False, False, None)
        start = os.path.join(base_root, wo_folder, 'Quality', 'Out_Doku')
        if not os.path.exists(start):
            return _doc(False, False, None)
        for root, _, files in os.walk(start):
            for f in files:
                if f.lower().startswith('slave') and f.lower().endswith('.pdf'):
                    return _doc(True, True, os.path.join(root, f))
        return _doc(False, True, None)

    def _testarchiv():
        if not archive_root or not esn:
            return _doc(False, False, None)
        wo_norm = wo.replace('.', '')
        pattern = os.path.join(archive_root, esn, f'*{wo_norm}*')
        matches = [d for d in _glob.glob(pattern) if os.path.isdir(d)]
        if matches:
            return _doc(True, True, max(matches, key=os.path.getmtime))
        return _doc(False, False, None)

    return jsonify({'testarchiv': _testarchiv(), 'wrb': _wrb(), 'service_order': _service_order(), 'slave': _slave()})


@app.route('/api/kb/documents/open', methods=['POST'])
def kb_documents_open():
    import glob as _glob
    data         = request.get_json(force=True)
    wo           = data.get('wo', '').strip()
    esn          = data.get('esn', '').strip()
    engine_type  = data.get('engine_type', '').strip().upper()
    doc_type     = data.get('doc_type', '')
    action       = data.get('action', 'file')
    preservation = data.get('preservation', '').strip()

    base_root, wrb_folder, archive_root, mail_templates_path = _kb_get_template_paths(engine_type)

    path = None
    if doc_type == 'testarchiv':
        import glob as _glob
        wo_norm = wo.replace('.', '')
        if archive_root and esn:
            pattern = os.path.join(archive_root, esn, f'*{wo_norm}*')
            matches = [d for d in _glob.glob(pattern) if os.path.isdir(d)]
            if matches:
                path = max(matches, key=os.path.getmtime)
            elif action == 'create':
                import datetime
                today = datetime.date.today().strftime('%Y-%m-%d')
                folder_name = f'{today}_{wo_norm}0100'
                path = os.path.join(archive_root, esn, folder_name)
                os.makedirs(os.path.join(path, 'Data'), exist_ok=True)
        if path and os.path.exists(path):
            os.startfile(path)
            return jsonify({'ok': True, 'created': action == 'create'})
        return jsonify({'ok': False, 'reason': 'Testarchiv nicht gefunden oder angelegt'}), 404

    if doc_type == 'wrb':
        s_wo  = wo[0] + '.' + wo[1:] if len(wo) > 1 and wo[1] != '.' else wo
        files = _glob.glob(os.path.join(wrb_folder, f'*{s_wo}*.pdf'))
        path  = max(files, key=os.path.getmtime) if (action == 'file' and files) else wrb_folder

    elif doc_type == 'service_order':
        wo_folder = _kb_find_wo_folder(base_root, wo) if base_root else None
        if wo_folder:
            cs   = os.path.join(base_root, wo_folder, 'Customer_Support')
            dirs = [os.path.join(cs, 'Service_Order'), os.path.join(cs, 'Service Order'), cs]
            if action == 'file':
                files = []
                for d in dirs:
                    if os.path.exists(d):
                        files += [os.path.join(d, f) for f in os.listdir(d)
                                  if 'so' in f.lower() and f.lower().endswith('.xlsm') and not f.startswith('~$')]
                path = max(files, key=os.path.getmtime) if files else None
            else:
                path = next((d for d in dirs if os.path.exists(d)), None)

    elif doc_type == 'slave':
        wo_folder = _kb_find_wo_folder(base_root, wo) if base_root else None
        if wo_folder:
            start = os.path.join(base_root, wo_folder, 'Quality', 'Out_Doku')
            if action == 'file':
                for root, _, files in os.walk(start):
                    for f in files:
                        if f.lower().startswith('slave') and f.lower().endswith('.pdf'):
                            path = os.path.join(root, f); break
                    if path: break
            else:
                path = start if os.path.exists(start) else None

    if path and os.path.exists(path):
        os.startfile(path)
        return jsonify({'ok': True})
    return jsonify({'ok': False, 'reason': 'Pfad nicht gefunden'}), 404


@app.route('/api/kb/email', methods=['POST'])
def kb_email():
    import glob as _glob
    data         = request.get_json(force=True)
    wo           = data.get('wo', '').strip()
    engine_type  = data.get('engine_type', '').strip()
    esn          = data.get('esn', '').strip()
    customer      = data.get('customer',     '').strip()
    rating        = data.get('rating',       '').strip()
    preservation  = data.get('preservation', '').strip()
    template_file = data.get('template_file','').strip()

    from datetime import datetime as _dt
    try:
        pres_fmt = _dt.strptime(preservation, '%Y-%m-%d').strftime('%d.%m.%Y') if preservation else 'N/A'
    except ValueError:
        pres_fmt = preservation or 'N/A'

    base_root, wrb_folder, archive_root, mail_tpl_path = _kb_get_template_paths(engine_type.upper())

    if not mail_tpl_path:
        return jsonify({'ok': False, 'reason': 'Mail-Templates Pfad nicht konfiguriert'}), 400
    if not template_file:
        return jsonify({'ok': False, 'reason': 'Keine Template-Datei angegeben'}), 400
    oft_path = os.path.join(mail_tpl_path, template_file)
    if not os.path.exists(oft_path):
        return jsonify({'ok': False, 'reason': f'Template-Datei nicht gefunden: {oft_path}'}), 404

    # ── Resolve file links ───────────────────────────────────────────
    folder_link = archive_folder_link = file_link = batch1_link = batch2_link = 'N/A'

    if base_root:
        wo_folder = _kb_find_wo_folder(base_root, wo)
        if wo_folder:
            test_dir = os.path.join(base_root, wo_folder, 'Test')
            if os.path.exists(test_dir):
                folder_link = test_dir
                pdfs = [os.path.join(test_dir, f) for f in os.listdir(test_dir)
                        if 'testresults' in f.lower() and f.lower().endswith('.pdf')]
                if pdfs:
                    file_link = max(pdfs, key=os.path.getmtime)

    if archive_root and esn:
        esn_dir = os.path.join(archive_root, esn)
        if os.path.isdir(esn_dir):
            matches = [d for d in _glob.glob(os.path.join(esn_dir, f'*{wo}*')) if os.path.isdir(d)]
            if matches:
                arch = max(matches, key=os.path.getmtime)
                archive_folder_link = arch
                def _latest_pdf(folder, keyword):
                    try:
                        candidates = [os.path.join(folder, f) for f in os.listdir(folder)
                                      if keyword.lower() in f.lower() and f.lower().endswith('.pdf')]
                        return max(candidates, key=os.path.getmtime) if candidates else 'N/A'
                    except Exception:
                        return 'N/A'
                batch1_link = _latest_pdf(arch, 'batch_01_signed')
                batch2_link = _latest_pdf(arch, 'batch_02_signed')

    replacements = {
        'ENGINE_TYPE':              engine_type,
        'ENGINE_TYPE_RATING':       f"{engine_type}-{rating}" if rating else engine_type,
        'SN':                       esn,
        'WBS':                      wo,
        'CUSTOMER':                 customer,
        'RATING':                   rating or 'N/A',
        'PRESERVATION_DATE':        pres_fmt,
        'TESTRESULTS_FOLDER_LINK':  folder_link,
        'TESTRESULTS_FILE_LINK':    file_link,
        'TESTARCHIVE_FOLDER_LINK':  archive_folder_link,
        'BATCH1_FILE_LINK':         batch1_link,
        'BATCH2_FILE_LINK':         batch2_link,
    }
    link_keys = {'TESTRESULTS_FOLDER_LINK', 'TESTRESULTS_FILE_LINK',
                 'TESTARCHIVE_FOLDER_LINK', 'BATCH1_FILE_LINK', 'BATCH2_FILE_LINK'}

    # ── Open Outlook ─────────────────────────────────────────────────
    try:
        import pythoncom, win32com.client
        pythoncom.CoInitialize()
        try:
            outlook = win32com.client.Dispatch('Outlook.Application')
            item    = outlook.CreateItemFromTemplate(oft_path)
            for ph, val in replacements.items():
                target = '{' + ph + '}'
                try:
                    if item.Subject:
                        item.Subject = item.Subject.replace(target, str(val))
                except Exception:
                    pass
                try:
                    if hasattr(item, 'HTMLBody') and item.HTMLBody:
                        replacement = f'<a href="{val}">{val}</a>' if ph in link_keys and val != 'N/A' else str(val)
                        item.HTMLBody = item.HTMLBody.replace(target, replacement)
                    elif item.Body:
                        item.Body = item.Body.replace(target, str(val))
                except Exception:
                    pass
            item.Display(False)
            try:
                win32com.client.Dispatch('WScript.Shell').AppActivate('Outlook')
            except Exception:
                pass
            return jsonify({'ok': True})
        finally:
            pythoncom.CoUninitialize()
    except Exception as e:
        return jsonify({'ok': False, 'reason': str(e)}), 500


@app.route('/api/process/instances/<esn>/<wo>', methods=['GET'])
def get_process_instance(esn, wo):
    data = _load_instance(esn, wo)
    if data is None:
        return jsonify({'error': 'Nicht gefunden'}), 404
    return jsonify(data)

@app.route('/api/process/instances/<esn>/<wo>', methods=['POST'])
def post_process_instance(esn, wo):
    try:
        data = request.get_json(force=True)
        data['esn'] = esn
        data['wo']  = wo
        _save_instance(data)
        return jsonify({'ok': True})
    except Exception as e:
        return jsonify({'error': str(e)}), 500

@app.route('/api/process/instances/<esn>/<wo>/step/<step_id>', methods=['PATCH'])
def patch_process_step(esn, wo, step_id):
    data = _load_instance(esn, wo)
    if data is None:
        return jsonify({'error': 'Instanz nicht gefunden'}), 404
    body = request.get_json(force=True)
    if 'step_status' not in data:
        data['step_status'] = {}
    if step_id not in data['step_status']:
        data['step_status'][step_id] = {}
    data['step_status'][step_id].update(body)
    _save_instance(data)
    return jsonify({'ok': True})

@app.route('/api/process/instances/<esn>/<wo>', methods=['DELETE'])
def delete_process_instance(esn, wo):
    path = os.path.join(_PROCESS_INSTANCES_DIR, _instance_filename(esn, wo))
    if os.path.exists(path):
        os.remove(path)
    return jsonify({'ok': True})

# ── Recipes ──────────────────────────────────────────────────────

_RECIPES_FILE = os.path.join(_SETTINGS_DIR, 'recipes.json')

def _load_recipes():
    if os.path.exists(_RECIPES_FILE):
        with open(_RECIPES_FILE) as f:
            return json.load(f)
    return []

def _save_recipes(data):
    with open(_RECIPES_FILE, 'w') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)

@app.route('/api/manual', methods=['GET'])
def get_manual():
    path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'settings', 'manual.json')
    try:
        with open(path, encoding='utf-8') as f:
            return jsonify(json.load(f))
    except Exception:
        return jsonify({})

@app.route('/api/recipes', methods=['GET'])
def get_recipes():
    return jsonify(_load_recipes())

@app.route('/api/recipes', methods=['POST'])
def post_recipes():
    try:
        _save_recipes(request.get_json(force=True))
        return jsonify({"ok": True})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/settings', methods=['GET'])
def get_settings():
    s    = _load_settings()
    conn = s.get('connections', {})
    safe = {k: ('••••' if ('TOKEN' in k or 'PASSWORD' in k) and v else v)
            for k, v in conn.items()}
    return jsonify({'connections': safe, 'node_defaults': s.get('node_defaults', {})})

@app.route('/api/settings', methods=['POST'])
def post_settings():
    try:
        data    = request.get_json(force=True)
        current = _load_settings()
        for k, v in data.get('connections', {}).items():
            if v and v != '••••':
                current.setdefault('connections', {})[k] = v
                if k in FLOW_CONFIG:
                    FLOW_CONFIG[k] = v
        if 'node_defaults' in data:
            current['node_defaults'] = data['node_defaults']
        _save_settings(current)
        return jsonify({'ok': True})
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/api/flow/cipa_config', methods=['GET'])
def get_cipa_config():
    return jsonify(_load_cipa_config())

@app.route('/api/flow/cipa_config', methods=['PUT'])
def put_cipa_config():
    try:
        data = request.get_json(force=True)
        if not isinstance(data, dict):
            return jsonify({'error': 'Ungültiges Format'}), 400
        _save_cipa_config(data)
        return jsonify({'ok': True})
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/api/available_codes')
def get_available_codes():
    """Returns distinct _Code values for a given TARDIS project."""
    project = request.args.get('project', '').strip()
    if not project:
        return jsonify({'codes': []})
    try:
        from mtu.cae.pytardis import messages, filters
        import pandas as pd
        msgr = _get_messenger()
        qf   = filters.QueryFilter({
            'Project.Name':     project,
            'Measurement.Name': '[measured, measurement absolute]',
        })
        qf.append(('Channel.Name', 'lk', '_Code'))   # only fetch _Code channel — fast
        dc = msgr.send(messages.DataReadMessage(qf, source=FLOW_CONFIG['TARDIS_SOURCE']))
        if len(dc) == 0:
            return jsonify({'codes': []})
        codes = sorted(dc[0].values['_Code'].dropna().unique().tolist()) if '_Code' in dc[0].values.columns else []
        return jsonify({'codes': codes})
    except Exception as e:
        return jsonify({'error': str(e)}), 500

@app.route('/api/available_channels')
def get_available_channels():
    """Returns distinct channel names for a given TARDIS project (metadata query, no data loaded)."""
    project = request.args.get('project', '').strip()
    if not project:
        return jsonify({'channels': []})
    try:
        from mtu.cae.pytardis import messages, filters
        msgr = _get_messenger()
        qf   = filters.QueryFilter({'Project.Name': project})
        msg  = messages.QueryMessage(
            query_filter=qf,
            result_parameters={"resultType": "Channel", "columns": ["Channel.Name"]},
            source=FLOW_CONFIG['TARDIS_SOURCE']
        )
        df = msgr.query(msg).to_dataframe()
        internal = {'_Code', '_ScanNr', '_timestamp'}
        channels = sorted(n for n in df['Channel.Name'].dropna().unique().tolist()
                          if n not in internal)
        return jsonify({'channels': channels})
    except Exception as e:
        return jsonify({'error': str(e)}), 500


def _batch_scan_check_internal(test_id, engine_type):
    """Core logic: query TARDIS for all scan codes of a recipe and return pass/fail per scan-nr.
    Returns {code: {scanNr: {channel: val, __ok: bool, __violations: [...]}}}

    Two-step query strategy:
      Step 1 — broad query (_ScanNr + _Code only) to discover ALL scan numbers per code,
               independent of which parameter channels exist in those ChannelGroups.
      Step 2 — narrow query (parameter + limit channels) to fetch values for limit checks.
    This prevents the channel filter from hiding scans whose _Code CG does not contain
    any of the recipe parameter channels.
    """
    from mtu.cae.pytardis import messages, filters
    import pandas as pd
    import math as _math
    recipe = next((r for r in _load_recipes() if r.get('engine_type') == engine_type), None)
    if not recipe:
        raise ValueError(f'Recipe nicht gefunden: {engine_type}')
    required_scans = recipe.get('required_scans', [])
    if not required_scans:
        return {}

    # Build specs and collect all parameter channels needed for Step 2
    param_channels = {'_ScanNr', '_timestamp'}
    specs = []
    for scan in required_scans:
        params       = scan.get('parameters', [])
        channels     = [p['channel'] for p in params if p.get('channel')]
        lim_channels = []
        for p in params:
            for lim in [p.get('limit_min', ''), p.get('limit_max', '')]:
                if lim and not _re.match(r'^[\d.eE+\-]+$', str(lim)):
                    lim_channels.append(lim)
        param_channels.update(channels)
        param_channels.update(lim_channels)
        specs.append({'code': scan['code'], 'params': params,
                      'channels': channels, 'lim_channels': lim_channels})

    msgr    = _get_messenger()
    MEA     = '[measured, measurement absolute]'
    source  = FLOW_CONFIG["TARDIS_SOURCE"]

    # ── Step 1: Discover all (code, scan_nr) pairs ────────────────────
    qf1 = filters.QueryFilter({"Test.Id": test_id, "Measurement.Name": MEA})
    qf1.append(("Channel.Name", "in", ["_ScanNr", "_Code"]))
    dc1 = msgr.send(messages.DataReadMessage(qf1, source=source))
    if len(dc1) == 0:
        return {spec['code']: {} for spec in specs}
    frames1 = [item.values for item in dc1]
    combined_codes = pd.concat(frames1, ignore_index=True)
    # Diagnostik: Gesamtzeilen pro CG (hilft erkennen ob TARDIS serverseitig limitiert)
    _cg_row_counts = {f'CG[{i}]': len(f) for i, f in enumerate(frames1)}

    # ── Step 2: Fetch parameter/limit channel values ──────────────────
    # Only query if the recipe actually has parameter channels to check
    has_params = param_channels - {'_ScanNr', '_timestamp'}
    if has_params:
        qf2 = filters.QueryFilter({"Test.Id": test_id, "Measurement.Name": MEA})
        qf2.append(("Channel.Name", "in", list(param_channels)))
        dc2 = msgr.send(messages.DataReadMessage(qf2, source=source))
        combined_vals = pd.concat([item.values for item in dc2], ignore_index=True) if len(dc2) > 0 else pd.DataFrame()
    else:
        combined_vals = pd.DataFrame()

    # ── Build result ──────────────────────────────────────────────────
    result = {}
    for spec in specs:
        code = spec['code']
        if '_Code' not in combined_codes.columns:
            result[code] = {}
            continue

        # Scan numbers from Step 1 — complete and unaffected by param channel filter
        code_df  = combined_codes[combined_codes['_Code'] == code]
        scan_nrs = code_df['_ScanNr'].dropna().unique() if '_ScanNr' in code_df.columns else []

        code_result = {}
        for nr_raw in scan_nrs:
            nr = str(nr_raw)
            if not nr:
                continue

            # Channel values from Step 2 (merge across all CGs for this scan number)
            if not combined_vals.empty and '_ScanNr' in combined_vals.columns:
                scan_rows = combined_vals[combined_vals['_ScanNr'].astype(str) == nr]
            else:
                scan_rows = pd.DataFrame()

            merged = {}
            for col in scan_rows.columns:
                for v in scan_rows[col]:
                    if v is None or (isinstance(v, float) and _math.isnan(v)):
                        continue
                    merged[col] = v
                    break

            vals = {}
            for ch in ['_timestamp'] + spec['channels'] + spec['lim_channels']:
                v = merged.get(ch)
                vals[ch] = str(v) if v is not None else ''

            violations = []
            for p in spec['params']:
                ch = p.get('channel', '')
                if not ch or ch not in merged:
                    continue
                try:
                    fval   = float(merged[ch])
                    lo_def = p.get('limit_min', '')
                    hi_def = p.get('limit_max', '')
                    try:
                        if lo_def:
                            loval = float(lo_def) if _re.match(r'^[\d.eE+\-]+$', str(lo_def)) else float(merged[lo_def]) if lo_def in merged else None
                            if loval is not None and fval < loval:
                                violations.append(f'{ch} < {loval}')
                    except Exception:
                        pass
                    try:
                        if hi_def:
                            hival = float(hi_def) if _re.match(r'^[\d.eE+\-]+$', str(hi_def)) else float(merged[hi_def]) if hi_def in merged else None
                            if hival is not None and fval > hival:
                                violations.append(f'{ch} > {hival}')
                    except Exception:
                        pass
                except (ValueError, TypeError):
                    pass

            vals['__ok']         = len(violations) == 0
            vals['__violations'] = violations
            code_result[nr]      = vals
        result[code] = code_result
    # Diagnostik-Meta: Zeilenzahl pro CG aus Step 1, Gesamtzeilen combined_codes
    result['_meta'] = {
        'step1_total_rows':  len(combined_codes),
        'step1_cg_rows':     _cg_row_counts,
        'step1_has_code_col': '_Code' in combined_codes.columns,
        'step1_has_scannr_col': '_ScanNr' in combined_codes.columns,
    }
    return result


@app.route('/api/flow/batch_scan_check')
def flow_batch_scan_check():
    """Queries all scan codes of a recipe for a test-id and returns values + pass/fail per scan-nr."""
    test_id     = request.args.get('test_id', '').strip()
    engine_type = request.args.get('engine_type', '').strip()
    if not test_id or not engine_type:
        return jsonify({"error": "test_id und engine_type erforderlich"}), 400
    try:
        return jsonify(_batch_scan_check_internal(test_id, engine_type))
    except ValueError as e:
        return jsonify({"error": str(e)}), 404
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/flow/scan_codes')
def flow_scan_codes():
    """Returns distinct _Code values for a given Test-ID."""
    test_id = request.args.get('test_id', '').strip()
    if not test_id:
        return jsonify({"error": "test_id erforderlich"}), 400
    try:
        from mtu.cae.pytardis import messages, filters
        import pandas as pd
        msgr = _get_messenger()
        qf = filters.QueryFilter({"Test.Id": test_id,
                                  "Measurement.Name": '[measured, measurement absolute]'})
        dc = msgr.send(messages.DataReadMessage(qf, source=FLOW_CONFIG["TARDIS_SOURCE"]))
        if len(dc) == 0:
            return jsonify({"codes": []})
        frames = [item.values for item in dc]
        combined = pd.concat(frames, ignore_index=True)
        if '_Code' not in combined.columns:
            return jsonify({"codes": []})
        codes = sorted(combined['_Code'].dropna().unique().tolist())
        return jsonify({"codes": codes})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/flow/cert_download/<run_id>')
def cert_download(run_id):
    if not _re.match(r'^[a-f0-9]{10}$', run_id):
        return jsonify({"error": "Ungültige ID"}), 400
    zip_path = os.path.join(os.path.dirname(__file__), 'flows', f'cert_{run_id}.zip')
    if not os.path.exists(zip_path):
        return jsonify({"error": "Datei nicht mehr verfügbar"}), 404
    from flask import send_file
    return send_file(zip_path, mimetype='application/zip',
                     as_attachment=True, download_name=f'cert_{run_id}.zip')


@app.route('/api/flow/pdf_download/<path:pdf_name>')
def pdf_download(pdf_name):
    # Sanitise: only allow safe filename characters + .pdf extension
    if not _re.match(r'^[a-zA-Z0-9_\-]+\.pdf$', pdf_name):
        return jsonify({'error': 'Ungültiger Dateiname'}), 400
    pdf_path = os.path.join(os.path.dirname(__file__), 'reports', pdf_name)
    if not os.path.exists(pdf_path):
        return jsonify({'error': 'Datei nicht mehr verfügbar'}), 404
    from flask import send_file
    return send_file(pdf_path, mimetype='application/pdf',
                     as_attachment=True, download_name=pdf_name)


@app.route('/api/hb/orders')
def hb_orders():
    import httpx
    try:
        with httpx.Client(timeout=15) as c:
            r = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-orders",
                      params={"testbed": FLOW_CONFIG["HB_TESTBED"]},
                      headers=_hb_auth_headers())
            r.raise_for_status()
            data = r.json()
        # Normalize — API may return list or wrapped object
        orders = data if isinstance(data, list) else (
            data.get('orders') or data.get('items') or data.get('results') or [data]
        )
        return jsonify(orders)
    except Exception as e:
        return jsonify({"error": str(e)}), 500


_status_cache = {"tardis": False, "hyperboost": False, "ts": 0.0}

@app.route('/api/flow/status')
def flow_status():
    import time
    if time.time() - _status_cache["ts"] < 600:
        return jsonify({"tardis": _status_cache["tardis"],
                        "hyperboost": _status_cache["hyperboost"],
                        "cached": True})
    tardis_ok = hb_ok = False
    try:
        _get_messenger()
        tardis_ok = True
    except Exception:
        pass
    try:
        import httpx
        with httpx.Client(timeout=3) as c:
            r = c.post(f"{FLOW_CONFIG['HB_BASE']}/auth/login",
                       json={"username": FLOW_CONFIG["HB_USER"],
                             "password": FLOW_CONFIG["HB_PASSWORD"]})
            hb_ok = r.status_code < 500
    except Exception:
        pass
    _status_cache.update({"tardis": tardis_ok, "hyperboost": hb_ok, "ts": time.time()})
    return jsonify({"tardis": tardis_ok, "hyperboost": hb_ok, "cached": False})


@app.route('/api/hb/find_order')
def hb_find_order():
    """Find a HyperBoost test order by ESN and/or WO (buildnumber)."""
    import httpx
    esn = request.args.get('esn', '').strip()
    wo  = request.args.get('wo', '').strip()
    if not esn and not wo:
        return jsonify({'error': 'esn oder wo erforderlich'}), 400
    try:
        with httpx.Client(timeout=15) as c:
            r = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-orders",
                      params={"testbed": FLOW_CONFIG["HB_TESTBED"]},
                      headers=_hb_auth_headers())
            r.raise_for_status()
            raw = r.json()
        orders = raw if isinstance(raw, list) else (
            raw.get('orders') or raw.get('items') or raw.get('results') or []
        )
        wo_norm = wo.replace('.', '')
        for o in orders:
            o_esn     = str(o.get('esn') or o.get('ESN') or
                            o.get('engine_serial_number') or '').strip()
            o_wo      = str(o.get('buildnumber') or o.get('build_number') or
                            o.get('BuildNumber') or '').strip()
            o_wo_norm = o_wo.replace('.', '')
            esn_ok = not esn or esn in o_esn or o_esn.endswith(esn)
            wo_ok  = not wo  or wo_norm in o_wo_norm or o_wo_norm.endswith(wo_norm)
            if esn_ok and wo_ok and (esn_ok or wo_ok):
                order_id = str(o.get('identifier') or o.get('Identifier') or o.get('id', ''))
                return jsonify({'order_id': order_id, 'esn': o_esn, 'wo': o_wo})
        return jsonify({'error': f'Kein HB Order für ESN={esn} WO={wo} gefunden'})
    except Exception as e:
        return jsonify({'error': str(e)}), 500


def _hb_flatten(d, _skip=('procedures', 'testProcedures', 'steps', 'testSteps')):
    """Flatten a nested HB metadata dict to {key: str_value}, skipping list-type fields."""
    out = {}
    def _collect(obj, prefix=''):
        if not isinstance(obj, dict):
            return
        for k, v in obj.items():
            if k in _skip:
                continue
            full_key = f'{prefix}{k}' if prefix else k
            if isinstance(v, (str, int, float)):
                s = str(v).strip()
                if s and s not in ('nan', 'None'):
                    out[full_key] = s
            elif isinstance(v, dict):
                _collect(v, f'{full_key}.')
    _collect(d)
    return out


@app.route('/api/hb/order_detail')
def hb_order_detail():
    """Return HB order metadata + procedures for display on /wave."""
    import httpx
    order_id = request.args.get('order_id', '').strip()
    if not order_id:
        return jsonify({'error': 'order_id erforderlich'}), 400
    try:
        with httpx.Client(timeout=15) as c:
            r = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-orders",
                      params={"testbed": FLOW_CONFIG["HB_TESTBED"]},
                      headers=_hb_auth_headers())
            r.raise_for_status()
            raw = r.json()
            orders = raw if isinstance(raw, list) else (
                raw.get('orders') or raw.get('items') or raw.get('results') or []
            )
            order = next((o for o in orders
                          if str(o.get('identifier') or o.get('Identifier') or o.get('id', '')) == str(order_id)),
                         None)
            if order is None:
                return jsonify({'error': f'Order {order_id} nicht gefunden'})
            result = {
                'order_id':    order_id,
                'order_name':  str(order.get('title') or order.get('name') or ''),
                'esn':         str(order.get('esn') or order.get('ESN') or ''),
                'buildnumber': str(order.get('buildnumber') or order.get('build_number') or order.get('BuildNumber') or ''),
                'engine_type': str(order.get('engine_type') or order.get('engineType') or ''),
                'testcell':    str(order.get('testcell') or order.get('testCell') or ''),
                'test_type':   str(order.get('test_type') or order.get('testType') or ''),
            }
            try:
                rp = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-order/testProcedures",
                           params={"test_order_id": order_id,
                                   "include_steps": True,
                                   "include_instructions": True,
                                   "exclude_actions": True},
                           headers=_hb_auth_headers())
                rp.raise_for_status()
                raw_procs = rp.json()
                procs_list = raw_procs if isinstance(raw_procs, list) else (
                    raw_procs.get('procedures') or raw_procs.get('testProcedures')
                    or raw_procs.get('items') or raw_procs.get('results') or []
                )
                procedures = []
                for p in procs_list:
                    raw_steps = p.get('testSteps') or p.get('steps') or []
                    steps = [{
                        'title': s.get('title') or s.get('name') or '',
                        'type':  (s.get('testStepType') or {}).get('name')
                                 if isinstance(s.get('testStepType'), dict)
                                 else (s.get('testStepType') or s.get('stepType') or ''),
                    } for s in raw_steps]
                    procedures.append({
                        'name':  p.get('name') or p.get('identifier') or '',
                        'title': p.get('title') or p.get('name') or '',
                        'type':  (p.get('testProcedureType') or {}).get('name')
                                 if isinstance(p.get('testProcedureType'), dict)
                                 else (p.get('testProcedureType') or p.get('procedure_type_name') or ''),
                        'steps': steps,
                    })
                result['procedures'] = procedures
            except Exception:
                result['procedures'] = []
            esn   = result['esn']
            build = result['buildnumber']
            if esn and build:
                try:
                    ri = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-info",
                               params={"esn": esn, "buildnumber": build,
                                       "testbed": FLOW_CONFIG["HB_TESTBED"]},
                               headers=_hb_auth_headers())
                    raw_ti = ri.json() if ri.status_code != 404 else {}
                    result['test_info_params'] = _hb_flatten(raw_ti)
                except Exception:
                    result['test_info_params'] = {}
            else:
                result['test_info_params'] = {}
        return jsonify(result)
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/api/flow/run', methods=['POST'])
def flow_run():
    body        = request.get_json(force=True)
    nodes       = body.get('nodes', [])
    connections = body.get('connections', [])
    executor    = FlowExecutor(nodes, connections)
    results     = executor.run()
    return jsonify(results)

# ══════════════════════════════════════════════════════════════════
#  SHARED SIDEBAR — einmalig definiert, in beide Seiten injiziert
# ══════════════════════════════════════════════════════════════════

SIDEBAR_CSS = """
  #sidebar {
    width: 64px; flex-shrink: 0;
    background: var(--sb-bg);
    border-right: 1px solid var(--sb-border);
    display: flex; flex-direction: column; align-items: center;
    padding: 24px 0 20px;
    transition: width .28s cubic-bezier(.4,0,.2,1);
    overflow: hidden; z-index: 20;
  }
  #sidebar.expanded { width: 180px; }
  .sb-top { display:flex; align-items:center; justify-content:center; width:100%; margin-bottom:auto; }
  .sb-label { font-size:9px; font-weight:700; letter-spacing:0.18em; text-transform:uppercase;
    color:var(--sb-label); writing-mode:vertical-rl; transform:rotate(180deg);
    white-space:nowrap; cursor:pointer; transition:color .18s; user-select:none; }
  .sb-label:hover { color: var(--sb-accent); }
  #sidebar.expanded .sb-label { writing-mode:horizontal-tb; transform:none; padding:0 0 0 16px; flex:1; }
  .sb-nav { display:flex; flex-direction:column; align-items:center; gap:4px; width:100%; }
  .sb-item { display:flex; flex-direction:row; align-items:center; width:100%; padding:10px 0;
    text-decoration:none; border-left:2px solid transparent;
    transition:background .18s, border-color .18s; cursor:pointer; overflow:hidden; }
  .sb-item:hover { background:var(--sb-hover); border-left-color:var(--sb-hover-border); }
  .sb-item.active { background:var(--sb-active-bg); border-left-color:var(--sb-accent); }
  .sb-icon-wrap { width:64px; flex-shrink:0; display:flex; flex-direction:column; align-items:center; gap:4px; }
  .sb-icon { font-size:15px; color:var(--sb-icon); line-height:1; }
  .sb-item.active .sb-icon { color:var(--sb-accent); }
  .sb-num { font-size:8px; letter-spacing:0.12em; color:var(--sb-num); }
  .sb-item.active .sb-num { color:var(--sb-accent); }
  .sb-text { font-size:10px; letter-spacing:0.06em; color:var(--sb-label);
    white-space:nowrap; opacity:0; transform:translateX(-6px);
    transition:opacity .2s, transform .2s; pointer-events:none; }
  #sidebar.expanded .sb-text { opacity:1; transform:translateX(0); }
  .sb-item.active .sb-text { color:var(--sb-accent); }
  .sb-sep { width:32px; height:1px; background:var(--sb-border); margin:2px auto; transition:width .28s; }
  #sidebar.expanded .sb-sep { width:148px; }
"""

SIDEBAR_JS = """
function toggleSidebar(){
  const sb = document.getElementById('sidebar');
  sb.classList.toggle('expanded');
  localStorage.setItem('sb_expanded', sb.classList.contains('expanded') ? '1' : '0');
}
(function(){
  if(localStorage.getItem('sb_expanded') === '1')
    document.getElementById('sidebar').classList.add('expanded');
})();
"""


def sidebar_html(active):
    pages = [
        ('/palantir','◆', '00', 'Palantir',       'palantir'),
        ('/wave',    '∿', '01', 'Wave Flow',       'wave'),
        ('/cube',    '◎', '02', 'Cube',            'cube'),
        ('/terrain', '◿', '03', 'Terrain',         'terrain'),
        ('/flow',    '⬢', '04', 'Flow Editor',     'flow'),
        ('/fan',     '⬡', '05', 'Fan Animation',  'fan'),
        ('/torus',   '◈', '06', 'Torus',           'torus'),
    ]
    items = ''
    for i, (href, icon, num, label, key) in enumerate(pages):
        cls = 'sb-item active' if key == active else 'sb-item'
        sep = '<div class="sb-sep"></div>' if i > 0 else ''
        items += (
            f'{sep}<a href="{href}" class="{cls}" title="{label}">'
            f'<div class="sb-icon-wrap"><span class="sb-icon">{icon}</span>'
            f'<span class="sb-num">{num}</span></div>'
            f'<span class="sb-text">{label}</span></a>'
        )
    return (
        '<div id="sidebar"><div class="sb-top">'
        '<span class="sb-label" onclick="toggleSidebar()">Navigation</span>'
        f'</div><nav class="sb-nav">{items}</nav></div>'
    )


# ══════════════════════════════════════════════════════════════════
#  PALANTIR PAGE  (/palantir)
# ══════════════════════════════════════════════════════════════════

PALANTIR_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Palantir — 00</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600&display=swap" rel="stylesheet">
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  :root {
    --sb-bg:          rgba(239,233,222,0.97);
    --sb-border:      rgba(232,70,70,0.14);
    --sb-label:       rgba(180,40,40,0.55);
    --sb-accent:      #e84646;
    --sb-hover:       rgba(232,70,70,0.07);
    --sb-hover-border:rgba(232,70,70,0.40);
    --sb-active-bg:   rgba(232,70,70,0.08);
    --sb-icon:        rgba(200,50,50,0.60);
    --sb-num:         rgba(200,50,50,0.40);
  }

  __SIDEBAR_CSS__

  body {
    background: #efe9de;
    display: flex;
    flex-direction: row;
    height: 100vh;
    overflow: hidden;
    font-family: 'Inter', sans-serif;
  }

  #main {
    flex: 1;
    overflow-y: auto;
    height: 100vh;
    background: #efe9de;
  }

  .scroll-space { height: 200vh; }

  .sticky-wrapper {
    position: sticky;
    top: 0;
    height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
    overflow: hidden;
  }

  .canvas {
    width: 100%;
    height: 100%;
    display: flex;
    overflow: hidden;
  }

  .anim-bar { will-change: transform; }

  .text-main    { font-family: 'Inter', sans-serif; font-weight: 500; fill: #e84646; }
  .text-diamond { font-family: 'Inter', sans-serif; font-weight: 600; fill: #e84646;
                  font-size: 34px; text-anchor: middle; dominant-baseline: central;
                  pointer-events: none; }
  .text-small   { font-family: 'Inter', sans-serif; font-weight: 600; fill: #e84646;
                  font-size: 11px; letter-spacing: 1px; }
  .geo          { stroke: #e84646; stroke-width: 2.2; fill: none; stroke-linejoin: round; }
  .diamond-shape{ fill: #efe9de; }
</style>
</head>
<body>
__SIDEBAR_HTML__
<div id="main">
  <div class="scroll-space">
    <div class="sticky-wrapper">
      <div class="canvas">
        <svg id="palantir-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="100%" height="100%" preserveAspectRatio="xMinYMid meet">
          <rect width="100%" height="100%" fill="#efe9de" />

          <!-- User icon -->
          <g transform="translate(60, 50)" class="geo">
            <circle cx="12" cy="10" r="5" />
            <path d="M 5 19 A 8 8 0 0 0 19 19" />
          </g>

          <!-- Main headline -->
          <text x="60" y="260" class="text-main" font-size="52" letter-spacing="-0.5">
            <tspan x="60" dy="0">A Day in the Life of a</tspan>
            <tspan x="60" dy="64">Palantir Deployment</tspan>
            <tspan x="60" dy="64">Strategist</tspan>
          </text>

          <!-- Airplane + Globe icons -->
          <g transform="translate(330, 350) scale(1.6) rotate(45 12 12)" fill="#e84646">
            <path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/>
          </g>
          <g transform="translate(385, 350) scale(1.6)" class="geo" stroke-width="1.5">
            <circle cx="12" cy="12" r="10"/>
            <line x1="2" y1="12" x2="22" y2="12"/>
            <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
          </g>

          <!-- Footer text -->
          <text x="60" y="560" class="text-small">
            <tspan x="60" dy="0">MTU AERO ENGINES</tspan>
            <tspan x="60" dy="16">TARDIS VIEWER — 2026</tspan>
          </text>
          <text x="290" y="576" class="text-small">©2026</text>

          <!-- Animated diamond blocks: Y O E P L D + empty -->
          <g class="anim-bar">
            <g class="geo">
              <line x1="750" y1="150" x2="1200" y2="150" />
              <line x1="795" y1="205" x2="1200" y2="205" />
              <polygon class="diamond-shape" points="750,150 795,205 750,260 705,205" />
            </g>
            <text x="750" y="207" class="text-diamond">Y</text>
          </g>

          <g class="anim-bar">
            <g class="geo">
              <line x1="840" y1="260" x2="1200" y2="260" />
              <polygon class="diamond-shape" points="795,205 840,260 795,315 750,260" />
            </g>
            <text x="795" y="262" class="text-diamond">O</text>
          </g>

          <g class="anim-bar">
            <g class="geo">
              <polygon class="diamond-shape" points="660,260 705,315 660,370 615,315" />
            </g>
            <text x="660" y="317" class="text-diamond">E</text>
          </g>

          <g class="anim-bar">
            <g class="geo">
              <line x1="750" y1="370" x2="1200" y2="370" />
              <polygon class="diamond-shape" points="750,260 795,315 750,370 705,315" />
            </g>
            <text x="750" y="317" class="text-diamond">P</text>
          </g>

          <g class="anim-bar">
            <g class="geo">
              <line x1="885" y1="315" x2="1200" y2="315" />
              <polygon class="diamond-shape" points="840,260 885,315 840,370 795,315" />
            </g>
            <text x="840" y="317" class="text-diamond">L</text>
          </g>

          <g class="anim-bar">
            <g class="geo">
              <line x1="795" y1="425" x2="1200" y2="425" />
              <line x1="750" y1="480" x2="1200" y2="480" />
              <polygon class="diamond-shape" points="750,370 795,425 750,480 705,425" />
            </g>
            <text x="750" y="427" class="text-diamond">D</text>
          </g>

          <g class="anim-bar">
            <g class="geo">
              <polygon class="diamond-shape" points="705,315 750,370 705,425 660,370" />
            </g>
          </g>
        </svg>
      </div>
    </div>
  </div>
  <!-- Kanban board: inside #main, below scroll-space -->
  <div class="kb-wrapper">
  <div class="kb-inner">
  <div class="kb-board">
    <div class="kb-board-heading">Engine Shop Order Tracker</div>
    <div class="kb-board-btns">
      <button class="kb-tpl-icon-btn" onclick="kbTardisOpen()" title="TARDIS Testdaten">⬡</button>
      <button class="kb-tpl-icon-btn" onclick="pfToggleTplEditor()" title="Template Editor">◈</button>
    </div>

    <!-- Shop (collapsed by default) -->
    <div class="kb-col collapsed" id="kb-col-shop" data-label="Shop" onclick="kbToggle('shop')">
      <div class="kb-col-header">
        <div class="kb-col-title-wrap">
          <span class="kb-col-icon">◈</span>
          <span class="kb-col-title">Shop</span>
        </div>
        <span class="kb-col-count">0</span>
        <span class="kb-col-toggle">◂</span>
      </div>
      <div class="kb-col-search-wrap" onclick="event.stopPropagation()">
        <input class="kb-col-search" id="kb-search-shop" placeholder="ESN / WO …"
               oninput="kbColSearch('shop', this.value)" onclick="event.stopPropagation()">
      </div>
      <script id="sap-data-script" type="application/json">__SAP_DATA__</script>
      <div class="kb-cards" id="kb-cards-shop"></div>
    </div>

    <!-- Planned -->
    <div class="kb-col" id="kb-col-planned" data-label="Planned"
         onclick="if(this.classList.contains('collapsed')) kbToggle('planned')">
      <div class="kb-col-header" onclick="event.stopPropagation(); kbToggle('planned')">
        <div class="kb-col-title-wrap">
          <span class="kb-col-icon">◇</span>
          <span class="kb-col-title">Planned</span>
        </div>
        <span class="kb-col-count">0</span>
        <span class="kb-col-toggle">◂</span>
      </div>
      <div class="kb-cards" id="kb-cards-planned"></div>
    </div>

    <!-- Split: In Test Cell (top, fixed) + On Hold (bottom, grows) -->
    <div class="kb-col-split" id="kb-col-split" data-label="Test · Hold"
         onclick="if(this.classList.contains('collapsed')) kbToggle('split')">

      <div class="kb-subcol kb-subcol-fixed" id="kb-sub-intest">
        <div class="kb-col-header" onclick="event.stopPropagation(); kbToggle('split')">
          <div class="kb-col-title-wrap">
            <span class="kb-col-icon">▸</span>
            <span class="kb-col-title">In Test Cell</span>
          </div>
          <span class="kb-col-count">0</span>
          <span class="kb-col-toggle">◂</span>
        </div>
        <div class="kb-cards" id="kb-cards-intest"></div>
      </div>

      <div class="kb-subcol kb-subcol-grow" id="kb-sub-onhold">
        <div class="kb-col-header" onclick="event.stopPropagation()">
          <div class="kb-col-title-wrap">
            <span class="kb-col-icon">◫</span>
            <span class="kb-col-title">On Hold</span>
          </div>
          <span class="kb-col-count">0</span>
        </div>
        <div class="kb-cards" id="kb-cards-onhold"></div>
      </div>

    </div>

    <!-- PaperWork -->
    <div class="kb-col" id="kb-col-paperwork" data-label="PaperWork"
         onclick="if(this.classList.contains('collapsed')) kbToggle('paperwork')">
      <div class="kb-col-header" onclick="event.stopPropagation(); kbToggle('paperwork')">
        <div class="kb-col-title-wrap">
          <span class="kb-col-icon">▤</span>
          <span class="kb-col-title">PaperWork</span>
        </div>
        <span class="kb-col-count">0</span>
        <span class="kb-col-toggle">◂</span>
      </div>
      <div class="kb-cards" id="kb-cards-paperwork"></div>
    </div>

    <!-- Tested (collapsed by default) -->
    <div class="kb-col collapsed" id="kb-col-tested" data-label="Tested" onclick="kbToggle('tested')">
      <div class="kb-col-header">
        <div class="kb-col-title-wrap">
          <span class="kb-col-icon">◆</span>
          <span class="kb-col-title">Tested</span>
        </div>
        <span class="kb-col-count">0</span>
        <span class="kb-col-toggle">◂</span>
      </div>
      <div class="kb-col-search-wrap" onclick="event.stopPropagation()">
        <input class="kb-col-search" id="kb-search-tested" placeholder="ESN / WO …"
               oninput="kbColSearch('tested', this.value)" onclick="event.stopPropagation()">
      </div>
      <div class="kb-cards" id="kb-cards-tested"></div>
    </div>

  </div>
  <!-- ── Detail panel ── -->
  <div class="kb-detail" id="kb-detail">
    <div class="kb-detail-inner">
      <!-- ── Header row ── -->
      <div class="kb-detail-header-row">
        <button class="kb-detail-close" onclick="kbCloseDetail()">&#8592;</button>
        <button class="kb-detail-delete" onclick="kbDeleteCard()" title="Karte löschen">✕</button>
      </div>

      <!-- ── Hero: Engine links, Kreise rechts ── -->
      <div class="kb-detail-hero">
        <div class="kb-detail-hero-left">
          <div class="kb-detail-engine" id="kb-detail-engine"></div>
          <span class="kb-detail-status" id="kb-detail-status"></span>
        </div>
        <div class="kb-detail-hero-right">
          <svg class="kb-detail-progress" viewBox="0 0 24 24" title="Fortschritt">
            <circle class="kb-progress-bg" cx="12" cy="12" r="9"/>
            <circle class="kb-progress-fg" cx="12" cy="12" r="9" id="kb-detail-pfg"/>
            <text class="kb-progress-txt" x="12" y="12" id="kb-detail-pct"></text>
          </svg>
          <svg class="kb-wave-btn-circle" viewBox="0 0 24 24" onclick="kbInjectToWave()" title="In Wave öffnen" focusable="false" tabindex="-1">
            <circle class="kb-wbc-ring" cx="12" cy="12" r="9"/>
            <path class="kb-wbc-wave" d="M6,12 C7.1,9.5 8.9,9.5 10,12 C11.1,14.5 12.9,14.5 14,12 C15.1,9.5 16.9,9.5 18,12" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </div>
      </div>

      <hr class="kb-detail-divider">

      <!-- ── Metadaten ── -->
      <div class="kb-detail-meta">
        <div class="kb-detail-meta-rows">
          <div class="kb-meta-col">
            <div class="kb-detail-meta-row"><span class="kb-detail-meta-label">ESN</span><span id="kb-detail-esn"></span></div>
            <div class="kb-detail-meta-row"><span class="kb-detail-meta-label">WO</span><span id="kb-detail-wo"></span></div>
            <div class="kb-detail-meta-row"><span class="kb-detail-meta-label">Customer</span><span id="kb-detail-customer"></span></div>
          </div>
          <div class="kb-meta-col">
            <div class="kb-detail-meta-row"><span class="kb-detail-meta-label">Rating</span><span id="kb-detail-rating" class="kb-meta-editable" onclick="kbMetaEditStart('rating',this)" title="Klicken zum Bearbeiten">—</span></div>
            <div class="kb-detail-meta-row"><span class="kb-detail-meta-label">Preservation</span><span id="kb-detail-preservation" class="kb-meta-editable" onclick="kbMetaEditStart('preservation',this)" title="Klicken zum Bearbeiten">—</span></div>
            <div class="kb-detail-meta-row"><span class="kb-detail-meta-label">Engineer</span><span id="kb-detail-engineer" class="kb-meta-editable" onclick="kbMetaEditStart('engineer',this)" title="Klicken zum Bearbeiten">—</span></div>
          </div>
        </div>
        <div class="kb-detail-meta-ampel" id="kb-detail-ampel">
          <button class="kb-ampel-item" id="kb-doc-btn-testarchiv"    onclick="kbOpenDocument('testarchiv')"><span class="kb-ampel-dot doc-missing" id="kb-doc-dot-testarchiv"></span><span class="kb-action-label">Testarchiv</span></button>
          <button class="kb-ampel-item" id="kb-doc-btn-wrb"           onclick="kbOpenDocument('wrb')"><span class="kb-ampel-dot doc-missing" id="kb-doc-dot-wrb"></span><span class="kb-action-label">WRB</span></button>
          <button class="kb-ampel-item" id="kb-doc-btn-service_order" onclick="kbOpenDocument('service_order')"><span class="kb-ampel-dot doc-missing" id="kb-doc-dot-service_order"></span><span class="kb-action-label">Service Order</span></button>
          <button class="kb-ampel-item" id="kb-doc-btn-slave"         onclick="kbOpenDocument('slave')"><span class="kb-ampel-dot doc-missing" id="kb-doc-dot-slave"></span><span class="kb-action-label">Slave</span></button>
        </div>
        <div class="kb-detail-folder-col" id="kb-detail-folders"></div>
        <div class="kb-action-wrap" id="kb-action-wrap">
          <div class="kb-detail-meta-right" id="kb-detail-actions">
            <button class="kb-action-icon" id="kb-action-btn-namen" onclick="kbToggleActionPanel('namen')"><span class="kb-action-symbol">≡</span><span class="kb-action-label">Namen</span></button>
            <button class="kb-action-icon"><span class="kb-action-symbol">⬡</span><span class="kb-action-label">Verknüpfen</span></button>
            <button class="kb-action-icon" id="kb-action-btn-email" onclick="kbToggleActionPanel('email')"><span class="kb-action-symbol">⇡</span><span class="kb-action-label">Exportieren</span></button>
            <button class="kb-action-icon"><span class="kb-action-symbol">◫</span><span class="kb-action-label">Archivieren</span></button>
          </div>
          <div class="kb-action-panel" id="kb-action-panel">
            <div class="kb-action-panel-inner" id="kb-action-panel-inner"></div>
          </div>
        </div>
      </div>

      <hr class="kb-detail-divider" style="margin-top:16px">

      <!-- ── Prozesslanes ── -->
      <div class="kb-detail-pf" id="kb-detail-pf">
        <div class="kb-detail-pf-empty">Keine Prozessinstanz gefunden.</div>
      </div>
    </div>
  </div>
  </div><!-- /.kb-inner -->

  <!-- ══ Template Editor Drawer ════════════════════════════════ -->
  <div class="pf-tpl-wrap" id="pf-tpl-wrap">
    <div class="pf-tpl-drag-handle" onclick="pfToggleTplEditor()"></div>
    <div class="pf-tpl-header-row">
      <div class="pf-tpl-heading">Process Template Editor</div>
      <button class="pf-tpl-close-btn" onclick="pfToggleTplEditor()">×</button>
    </div>
    <div class="pf-tpl-toolbar">
      <div class="pf-tpl-tabs" id="pf-tpl-sel"></div>
      <button class="pf-edit-btn" id="pf-edit-tpl-btn" onclick="pfToggleTemplateEdit()">✎ Bearbeiten</button>
      <button class="pf-add-lane-btn" onclick="pfNewTemplate()">+ Neues Template</button>
    </div>
    <div class="pf-canvas" id="pf-tpl-canvas">
      <div class="pf-empty">Template auswählen…</div>
    </div>
  </div><!-- /.pf-tpl-wrap -->

  </div><!-- /.kb-wrapper -->

</div><!-- /#main -->

<!-- ── Flow Trigger Modal ────────────────────────────────────── -->
<!-- ── TARDIS Browser Modal ─────────────────────────────────── -->
<div class="kbt-backdrop" id="kbt-backdrop" onclick="kbTardisClose()" style="display:none"></div>
<div class="kbt-modal" id="kbt-modal" style="display:none">
  <div class="kbt-header">
    <span class="kbt-title">⬡ TARDIS Testdaten</span>
    <button class="kbt-close" onclick="kbTardisClose()">×</button>
  </div>
  <div class="kbt-filters">
    <select class="kbt-sel" id="kbt-proj-sel" onchange="kbTardisLoadPools()">
      <option value="">— Projekt wählen —</option>
    </select>
    <button class="kbt-load-btn" id="kbt-load-btn" onclick="kbTardisLoadTests()">▶ Laden</button>
  </div>
  <div class="kbt-search-row">
    <input class="kbt-search" id="kbt-search" placeholder="Testname · ESN · WO filtern …" oninput="kbTardisFilter()">
    <span class="kbt-count" id="kbt-count"></span>
  </div>
  <div class="kbt-list" id="kbt-list"></div>
  <div class="kbt-footer">
    <button class="kbt-more-btn" id="kbt-more-btn" style="display:none" onclick="kbTardisShowMore()">+ Weitere laden</button>
  </div>
</div>

<div class="pftm-backdrop" id="pftm-backdrop" onclick="pfCloseFlowModal()" style="display:none"></div>
<div class="pftm" id="pftm" style="display:none">
  <div class="pftm-header">
    <span class="pftm-title" id="pftm-title">Flow</span>
    <button class="pftm-close" onclick="pfCloseFlowModal()">×</button>
  </div>
  <div class="pftm-body" id="pftm-body"></div>
</div>

<script>
__SIDEBAR_JS__

(function() {
  const mainEl = document.getElementById('main');
  const bars     = document.querySelectorAll('.anim-bar');
  const diamonds = document.querySelectorAll('.diamond-shape');

  const MOUSE_RADIUS  = 200;
  const PUSH_DISTANCE = 120;
  const LERP_SPEED    = 0.12;

  const animConfig = [
    { delay: 0.05, speed: 1.5 },
    { delay: 0.15, speed: 1.2 },
    { delay: 0.30, speed: 0.9 },
    { delay: 0.20, speed: 1.1 },
    { delay: 0.00, speed: 1.8 },
    { delay: 0.10, speed: 1.4 },
    { delay: 0.25, speed: 1.0 },
  ];

  let scrollPercent  = 0;
  let mouseX = -1000, mouseY = -1000;
  let currentTranslateX = [0,0,0,0,0,0,0];

  // Scroll inside #main (not window)
  mainEl.addEventListener('scroll', () => {
    const maxScroll = mainEl.scrollHeight - mainEl.clientHeight;
    scrollPercent   = maxScroll > 0 ? Math.max(0, Math.min(1, mainEl.scrollTop / maxScroll)) : 0;
  });

  mainEl.addEventListener('mousemove', e => { mouseX = e.clientX; mouseY = e.clientY; });
  mainEl.addEventListener('mouseleave', () => { mouseX = -1000; mouseY = -1000; });

  function renderLoop() {
    bars.forEach((bar, index) => {
      const cfg     = animConfig[index];
      const diamond = diamonds[index];
      let progress  = (scrollPercent - cfg.delay) * cfg.speed;
      progress = Math.max(0, progress);
      let targetX = progress * 1200;

      const rect    = diamond.getBoundingClientRect();
      const centerX = rect.left + rect.width  / 2;
      const centerY = rect.top  + rect.height / 2;
      const distX   = mouseX - centerX;
      const distY   = mouseY - centerY;
      const dist    = Math.sqrt(distX*distX + distY*distY);
      if (dist < MOUSE_RADIUS) {
        const intensity = 1 - dist / MOUSE_RADIUS;
        targetX += intensity * intensity * PUSH_DISTANCE;
      }

      currentTranslateX[index] += (targetX - currentTranslateX[index]) * LERP_SPEED;
      bar.setAttribute('transform', `translate(${currentTranslateX[index]}, 0)`);
    });
    requestAnimationFrame(renderLoop);
  }

  requestAnimationFrame(renderLoop);
})();
</script>

<!-- ══ Kanban Board ══════════════════════════════════════════════ -->
<style>
  .kb-board-btns {
    position: absolute; top: 22px; right: 32px;
    display: flex; flex-direction: column; align-items: center; gap: 2px;
  }
  .kb-tpl-icon-btn {
    background: none; border: none; cursor: pointer;
    font-size: 20px; color: rgba(140,30,30,0.45); padding: 4px 8px;
    line-height: 1; transition: color 150ms;
  }
  .kb-tpl-icon-btn:hover { color: rgba(232,70,70,0.85); }
  .kb-tpl-icon-btn.active { color: rgba(232,70,70,0.9); }
  .kb-col-search-wrap {
    padding: 4px 8px 4px;
    display: none;
    border-left: 1px solid rgba(232,70,70,0.18);
    border-right: 1px solid rgba(232,70,70,0.18);
    background: rgba(232,70,70,0.03);
  }
  .kb-col:not(.collapsed) .kb-col-search-wrap { display:block; }
  .kb-col-search {
    width: 100%; box-sizing: border-box; padding: 3px 7px; font-size: 9px;
    font-family: inherit; border: 1px solid rgba(232,70,70,0.22); border-radius: 2px;
    background: rgba(255,255,255,0.55); color: rgba(80,10,10,0.80); outline: none;
  }
  .kb-col-search:focus { border-color: rgba(232,70,70,0.45); background: #fff; }
  .kb-card.kb-hidden { display: none; }

  /* ── Board wrapper (board + detail panel side-by-side) ── */
  .kb-wrapper {
    display: flex;
    flex-direction: column;
    width: 100%;
    border-top: 1.5px solid #e84646;
    position: sticky;
    top: 0;
    height: 100vh;
  }

  .kb-inner {
    display: flex;
    flex-direction: row;
    flex: 1;
    min-height: 0;
    overflow: hidden;
    align-items: stretch;
  }

  .kb-board {
    flex: 1;
    min-width: 0;
    overflow-y: auto;
    padding: 200px 32px 64px;
    display: flex;
    flex-direction: row;
    gap: 10px;
    align-items: stretch;
    box-sizing: border-box;
    position: relative;
  }

  .kb-board-heading {
    position: absolute;
    top: 68px; left: 32px;
    font-size: 38px; font-weight: 800;
    letter-spacing: 0.05em; text-transform: uppercase;
    color: rgba(140,30,30,0.55);
    pointer-events: none; user-select: none;
    line-height: 1.2;
  }

  /* ── Detail panel ── */
  .kb-detail {
    width: 0;
    overflow: hidden;
    transition: width 260ms cubic-bezier(.4,0,.2,1);
    border-left: 1.5px solid rgba(232,70,70,0.18);
    box-shadow: -6px 0 40px rgba(0,0,0,0.12);
    background: rgba(245,236,226,0.97);
    flex-shrink: 0;
    position: sticky;
    top: 0;
    height: 100vh;
    overflow-y: auto;
  }
  .kb-detail.open { width: 30vw; }
  .kb-detail.open.wide { width: 60vw; }
  .kb-detail-inner {
    width: 100%;
    min-height: 100%;
    padding: 20px 20px 32px;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .kb-detail-close {
    font-size: 32px;
    font-weight: 900;
    line-height: 1;
    color: rgba(180,40,40,0.60);
    background: none;
    border: none;
    cursor: pointer;
    padding: 0;
    align-self: flex-start;
    transition: color 120ms;
    margin-bottom: 4px;
  }
  .kb-detail-close:hover { color: #e84646; }
  .kb-detail-header-row {
    display: flex; align-items: center; justify-content: space-between;
    margin-bottom: 4px;
  }
  .kb-detail-delete {
    font-size: 22px; font-weight: 900; line-height: 1;
    color: rgba(180,40,40,0.35);
    background: none; border: none; cursor: pointer; padding: 2px 4px;
    border-radius: 4px; transition: color 120ms, background 120ms;
  }
  .kb-detail-delete:hover {
    color: #e84646; background: rgba(232,70,70,0.10);
  }
  .kb-detail-engine {
    font-size: 32px;
    font-weight: 800;
    color: rgba(120,20,20,0.88);
    letter-spacing: -0.02em;
  }
  .kb-detail-status {
    display: inline-block;
    font-size: 9px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
    color: rgba(180,40,40,0.70);
    background: rgba(232,70,70,0.10);
    border: 1px solid rgba(232,70,70,0.22);
    padding: 2px 8px;
    border-radius: 10px;
    align-self: flex-start;
  }
  .kb-detail-divider {
    border: none; border-top: 1px solid rgba(232,70,70,0.14); margin: 2px 0;
  }
  /* ── Hero row ── */
  .kb-detail-hero {
    display: flex; align-items: flex-start; justify-content: space-between;
    gap: 12px; padding: 2px 0 8px;
  }
  .kb-detail-hero-left { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
  .kb-detail-hero-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
  .kb-detail-progress { width: 56px; height: 56px; flex-shrink: 0; }

  /* ── Wave-Kreis ── */
  .kb-wave-btn-circle {
    width: 56px; height: 56px; flex-shrink: 0;
    cursor: pointer;
    outline: none; user-select: none; -webkit-user-select: none;
    -webkit-tap-highlight-color: transparent;
  }
  .kb-wave-btn-circle:focus, .kb-wave-btn-circle:focus-visible, .kb-wave-btn-circle:active {
    outline: none;
  }
  .kb-wbc-ring {
    fill: none; stroke: rgba(26,111,212,0.28); stroke-width: 1.2;
    transition: stroke 200ms ease;
  }
  .kb-wave-btn-circle:hover .kb-wbc-ring { stroke: #1a6fd4; }
  .kb-wbc-wave {
    stroke: rgba(26,111,212,0.38); stroke-width: 1.0;
    transition: stroke 200ms ease;
  }
  .kb-wave-btn-circle:hover .kb-wbc-wave { stroke: #1a6fd4; }

  /* ── Metadaten ── */
  .kb-detail-meta { display: flex; flex-direction: row; gap: 12px; align-items: flex-start; }
  .kb-detail-meta-rows { display: flex; flex-direction: row; gap: 16px; padding: 2px 0; flex: 1; }
  .kb-meta-col { display: flex; flex-direction: column; gap: 8px; flex: 1; }
  .kb-detail-meta-row {
    display: flex; align-items: baseline; gap: 10px;
    font-size: 15px; color: rgba(140,30,30,0.80);
  }
  .kb-detail-meta-label {
    font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
    color: rgba(180,40,40,0.40); min-width: 68px; white-space: nowrap; flex-shrink: 0;
  }
  .kb-meta-editable { cursor: text; min-width: 48px; border-radius: 3px; padding: 1px 3px; margin: -1px -3px;
    transition: background 120ms; }
  .kb-meta-editable:hover { background: rgba(232,70,70,0.07); }
  .kb-meta-input { background: transparent; border: none; border-bottom: 1px solid rgba(180,40,40,0.40);
    color: rgba(120,20,20,0.85); font-size: 15px; outline: none; width: 100%;
    font-family: inherit; padding: 0; }
  .kb-eng-wrap { position: relative; display: inline-block; width: 100%; }
  .kb-eng-drop { display: none; position: absolute; top: 100%; left: 0; min-width: 180px;
    background: var(--panel-bg, #fff); border: 1px solid var(--border-color, #ccc);
    border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.18); z-index: 300;
    max-height: 160px; overflow-y: auto; }
  .kb-eng-drop-item { padding: 5px 10px; font-size: 12px; cursor: pointer;
    color: var(--text-primary, #333); }
  .kb-eng-drop-item:hover { background: rgba(232,70,70,0.08); }
  .kb-eng-drop-add { color: #3b82f6; font-weight: 600; }
  .kb-detail-meta-ampel { display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
    padding: 4px 6px; border-left: 1px solid rgba(232,70,70,0.14); flex-shrink: 0; overflow: hidden; }
  .kb-ampel-item { background: none; border: none; cursor: pointer;
    display: flex; align-items: center; gap: 7px; flex-direction: row-reverse;
    padding: 3px 4px; border-radius: 4px;
    transition: background 120ms; line-height: 1; white-space: nowrap; }
  .kb-ampel-item:hover { background: rgba(232,70,70,0.06); }
  .kb-ampel-dot { width: 13px; height: 13px; border-radius: 50%; flex-shrink: 0;
    background: rgba(180,40,40,0.15); border: 1.5px solid rgba(180,40,40,0.22);
    transition: background 300ms, border-color 300ms; }
  .kb-ampel-dot.doc-loading { animation: kbDotPulse 900ms ease-in-out infinite; }
  .kb-ampel-dot.doc-found   { background: rgba(34,197,94,0.30); border-color: rgba(34,197,94,0.70); }
  .kb-ampel-dot.doc-folder  { background: rgba(234,179,8,0.30); border-color: rgba(234,179,8,0.70); }
  .kb-ampel-dot.doc-missing { background: rgba(180,40,40,0.15); border-color: rgba(180,40,40,0.22); }
  @keyframes kbDotPulse {
    0%,100% { opacity: 1; } 50% { opacity: 0.3; }
  }
  .kb-ampel-item .kb-action-label { font-size: 10px; font-weight: 600;
    letter-spacing: 0.05em; opacity: 0; max-width: 0; color: rgba(180,40,40,0.65);
    overflow: hidden; transition: opacity 180ms ease, max-width 220ms ease; }
  .kb-detail-meta-ampel:hover .kb-action-label { opacity: 1; max-width: 110px; }
  .kb-detail-folder-col { display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
    padding: 4px 6px; border-left: 1px solid rgba(232,70,70,0.14); flex-shrink: 0; overflow: hidden; }
  .kb-detail-folder-col:empty { display: none; }
  .kb-detail-folder-col:hover .kb-action-label { opacity: 1; max-width: 110px; }
  .kb-folder-dot { background: rgba(59,130,246,0.18); border-color: rgba(59,130,246,0.45); }
  .kb-folder-item:hover { background: rgba(59,130,246,0.07); }
  .kb-detail-meta-right { display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
    padding: 4px 6px; border-left: 1px solid rgba(232,70,70,0.14); flex-shrink: 0; overflow: hidden; }
  .kb-action-icon { background: none; border: none; cursor: pointer;
    display: flex; align-items: center; gap: 7px; flex-direction: row-reverse;
    color: rgba(180,40,40,0.40); padding: 3px 4px; border-radius: 4px;
    transition: color 120ms, background 120ms; line-height: 1; white-space: nowrap; }
  .kb-action-icon:hover { color: rgba(180,40,40,0.85); background: rgba(232,70,70,0.08); }
  .kb-action-icon .kb-action-symbol { font-size: 15px; flex-shrink: 0; }
  .kb-action-icon .kb-action-label { font-size: 10px; font-weight: 600;
    letter-spacing: 0.05em; opacity: 0; max-width: 0;
    overflow: hidden; transition: opacity 180ms ease, max-width 220ms ease; }
  .kb-detail-meta-right:hover .kb-action-label { opacity: 1; max-width: 90px; }
  .kb-detail-meta-right:hover .kb-action-icon { color: rgba(180,40,40,0.55); }
  .kb-action-wrap { display: flex; flex-direction: row; align-items: stretch; }
  .kb-action-panel {
    width: 0; overflow: hidden;
    transition: width 240ms cubic-bezier(0.4,0,0.2,1);
    border-left: 1px solid rgba(232,70,70,0.14);
    display: flex; align-items: flex-start;
  }
  .kb-action-panel-inner { padding: 4px 10px; display: flex; flex-direction: column; gap: 4px; white-space: nowrap; }
  .kb-action-wrap.panel-open .kb-action-label { opacity: 1; max-width: 90px; }
  .kb-action-wrap.panel-open .kb-action-icon { color: rgba(180,40,40,0.55); }
  .kb-action-wrap.panel-open .kb-action-icon.active { color: rgba(180,40,40,0.90); background: rgba(232,70,70,0.10); }
  .kb-aname-row { display: flex; align-items: center; gap: 10px; cursor: pointer;
    padding: 3px 6px; margin: 0 -6px; border-radius: 4px;
    transition: background 120ms; }
  .kb-aname-row:hover { background: rgba(232,70,70,0.08); }
  .kb-aname-row.flash-ok  { background: rgba(34,197,94,0.18);  transition: background 80ms; }
  .kb-aname-row.flash-err { background: rgba(239,68,68,0.18);  transition: background 80ms; }
  .kb-aname-row.flash-run { background: rgba(234,179,8,0.15);  transition: background 80ms; }
  .kb-aname-text { font-family: monospace; font-size: 10px; color: rgba(140,20,20,0.80); letter-spacing: 0.02em; flex: 1; }
  .kb-aname-label { font-size: 10px; color: rgba(140,20,20,0.80); flex: 1; }
  .kb-detail-notes {
    font-size: 13px; color: rgba(120,20,20,0.75);
    border: 1px solid rgba(232,70,70,0.18);
    background: rgba(255,255,255,0.40);
    padding: 8px 10px;
    resize: none;
    min-height: 90px;
    font-family: inherit;
    outline: none;
    flex: 1;
  }
  .kb-detail-notes:focus {
    border-color: rgba(232,70,70,0.40);
    background: rgba(255,255,255,0.65);
  }
  /* ── Process flat list (sidebar) ── */
  .kb-detail-pf { flex: 1; padding: 4px 0; }
  .kb-detail-pf-empty { font-size:13px; color:rgba(180,40,40,0.40); padding:4px 0; }
  .kb-tpl-confirm { display:inline-flex; flex-direction:column; gap:10px; padding:14px 16px; background:rgba(26,111,212,0.06); border:1px solid rgba(26,111,212,0.22); max-width:320px; }
  .kb-tpl-confirm-header { display:flex; align-items:center; gap:10px; }
  .kb-tpl-confirm-icon { font-size:28px; font-weight:900; color:rgba(232,70,70,0.70); line-height:1; flex-shrink:0; }
  .kb-tpl-confirm-label { font-size:10.5px; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; color:rgba(26,111,212,0.75); }
  .kb-tpl-confirm-sel { font-size:12px; padding:5px 8px; border:1px solid rgba(26,111,212,0.30); background:#fff; color:rgba(20,60,140,0.9); width:100%; }
  .kb-tpl-confirm-btns { display:flex; gap:8px; }
  .kb-tpl-confirm-ok { flex:1; padding:6px; background:rgba(26,111,212,0.12); border:1px solid rgba(26,111,212,0.35); color:rgba(20,60,140,0.9); font-size:12px; font-weight:600; cursor:pointer; }
  .kb-tpl-confirm-ok:hover { background:rgba(26,111,212,0.22); }
  .kb-tpl-confirm-cancel { padding:6px 10px; background:transparent; border:1px solid rgba(180,40,40,0.25); color:rgba(180,40,40,0.6); font-size:12px; cursor:pointer; }
  .kb-tpl-confirm-cancel:hover { background:rgba(232,70,70,0.07); }
  .pfa-header { display:flex; align-items:center; justify-content:space-between; padding:4px 0 10px; }
  .pfa-header-engine { font-size:14px; font-weight:700; color:rgba(100,20,20,0.80); }
  .pfa-header-meta { font-size:13px; color:rgba(180,40,40,0.50); }
  .pfa-edit-btn { background:none; border:1px solid rgba(232,70,70,0.25); color:rgba(180,40,40,0.60);
    padding:3px 9px; font-size:12px; cursor:pointer; font-family:inherit; border-radius:3px; }
  .pfa-edit-btn:hover { background:rgba(232,70,70,0.08); }
  .pfa-edit-btn.active { background:rgba(232,70,70,0.12); border-color:rgba(232,70,70,0.50); color:rgba(140,30,30,0.85); }
  /* Phase heading */
  .pfa-phase-heading {
    display:flex; align-items:center; gap:6px; padding:8px 0 4px;
    border-bottom:1px solid rgba(232,70,70,0.12); margin-bottom:2px;
  }
  .pfa-phase-icon { font-size:14px; color:rgba(180,40,40,0.40); }
  .pfa-phase-label { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:0.14em; color:rgba(140,30,30,0.55); flex:1; }
  .pfa-phase-prog { font-size:12px; color:rgba(180,40,40,0.40); }
  .pfa-phase-prog.done { color:#22c55e; }
  /* Step row */
  .pfa-step {
    display:flex; align-items:flex-start; gap:8px;
    padding:6px 6px 6px 8px; border-radius:3px;
    border-left:3px solid transparent;
    transition:background 80ms, border-color 80ms;
    background:rgba(180,60,20,0.055);
    border-left-color:rgba(180,60,20,0.18);
  }
  .pfa-step.st-running {
    background:rgba(245,158,11,0.10);
    border-left-color:rgba(245,158,11,0.60);
  }
  .pfa-step.st-error {
    background:rgba(232,70,70,0.10);
    border-left-color:rgba(232,70,70,0.60);
  }
  .pfa-step.st-done {
    background:rgba(34,197,94,0.04);
    border-left-color:rgba(34,197,94,0.25);
  }
  .pfa-step.st-skipped {
    background:transparent;
    border-left-color:transparent;
  }
  .pfa-step:hover { filter:brightness(0.97); }
  .pfa-step-icon { font-size:14px; flex-shrink:0; margin-top:1px; width:18px; text-align:center; color:rgba(140,30,30,0.50); cursor:pointer; }
  .pfa-step-icon.done    { color:#22c55e; }
  .pfa-step-icon.running { color:#f59e0b; }
  .pfa-step-icon.error   { color:#e84646; }
  .pfa-step-icon.skipped { color:rgba(180,40,40,0.25); }
  .pfa-step-body { flex:1; min-width:0; }
  .pfa-step-row1 { display:flex; align-items:center; gap:6px; }
  .pfa-step-label { font-size:14px; color:rgba(70,10,10,0.82); line-height:1.4; flex:1; }
  .pfa-step-label.done    { color:rgba(100,40,40,0.35); text-decoration:line-through; }
  .pfa-step-label.skipped { color:rgba(100,40,40,0.30); }
  .pfa-step-type-icon { font-size:13px; color:rgba(180,40,40,0.35); flex-shrink:0; }
  .pfa-step-ts { font-size:12px; color:rgba(180,40,40,0.38); margin-top:2px; }
  .pfa-step-actions { display:flex; gap:4px; flex-wrap:wrap; margin-top:5px; }
  .pfa-btn {
    padding:3px 10px; font-size:12px; font-family:inherit; border-radius:2px; cursor:pointer;
    border:1px solid rgba(232,70,70,0.20); background:none; color:rgba(140,30,30,0.70);
    transition:background 80ms; white-space:nowrap; line-height:1.5;
  }
  .pfa-btn:hover   { background:rgba(232,70,70,0.09); }
  .pfa-btn.start   { border-color:#f59e0b; color:#92400e; }
  .pfa-btn.done    { border-color:#22c55e; color:#166534; }
  .pfa-btn.flow    { border-color:rgba(99,102,241,0.40); color:rgba(67,56,202,0.78); }
  .pfa-btn.reset   { opacity:0.55; }
  .pfa-btn.reset:hover { opacity:1; }
  .pfa-step-edit { display:flex; flex-direction:column; gap:3px; margin-top:4px; }
  .pfa-add-step { padding:3px 0 3px 24px; }
  .pfa-add-step-btn { font-size:12px; font-family:inherit; padding:3px 10px; cursor:pointer;
    border:1px dashed rgba(232,70,70,0.28); background:none; color:rgba(140,30,30,0.50); border-radius:2px; }
  .pfa-add-step-btn:hover { background:rgba(232,70,70,0.07); }
  .pfa-tpl-row {
    display:flex; align-items:center; gap:8px;
    padding:5px 0 7px; border-bottom:1px solid rgba(232,70,70,0.10);
    margin-bottom:6px; font-size:13px;
  }
  .pfa-tpl-row-label {
    font-size:12px; font-weight:700; color:rgba(180,40,40,0.50);
    min-width:56px; flex-shrink:0;
  }
  .pfa-tpl-current { color:rgba(100,20,20,0.75); flex:1; }
  .pfa-tpl-select {
    font-size:12px; font-family:inherit; flex:1;
    border:1px solid rgba(232,70,70,0.25); background:rgba(239,233,222,0.80);
    color:rgba(100,20,20,0.80); padding:3px 6px; border-radius:3px; cursor:pointer;
    outline:none;
  }
  .pfa-tpl-select:focus { border-color:rgba(232,70,70,0.50); }
  .pfa-tpl-missing {
    font-size:11px; color:rgba(180,40,40,0.55); font-style:italic;
    padding:4px 0 2px;
  }

  /* ── Normal column ── */
  .kb-col {
    flex: 1;
    min-width: 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    position: relative;
    transition: flex 220ms ease;
    overflow: hidden;
  }

  /* ── Collapsed side columns (Shop / Tested) ── */
  .kb-col.collapsed {
    flex: 0 0 40px;
    cursor: pointer;
    border: 1px solid rgba(232,70,70,0.18);
    background: rgba(232,70,70,0.04);
  }
  .kb-col.collapsed:hover { background: rgba(232,70,70,0.09); }
  /* Hide all children when collapsed */
  .kb-col.collapsed > * { display: none; }
  /* Rotated label via ::after */
  .kb-col.collapsed::after {
    content: attr(data-label);
    display: block;
    position: absolute;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%) rotate(-90deg);
    white-space: nowrap;
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: rgba(180,40,40,0.60);
  }

  /* ── Column header ── */
  .kb-col-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px 12px;
    cursor: pointer;
    border: 1px solid rgba(232,70,70,0.18);
    border-bottom: none;
    background: rgba(232,70,70,0.06);
    user-select: none;
    gap: 8px;
    white-space: nowrap;
  }
  .kb-col-header:hover { background: rgba(232,70,70,0.11); }

  .kb-col-title-wrap { display: flex; align-items: center; gap: 7px; flex: 1; overflow: hidden; }
  .kb-col-icon { font-size: 12px; color: rgba(180,40,40,0.65); flex-shrink: 0; }
  .kb-col-title {
    font-size: 10.5px; font-weight: 700; letter-spacing: 0.10em;
    text-transform: uppercase; color: rgba(180,40,40,0.75);
    overflow: hidden; text-overflow: ellipsis;
  }
  .kb-col-count {
    font-size: 10px; font-weight: 600; color: rgba(180,40,40,0.55);
    background: rgba(232,70,70,0.10); border: 1px solid rgba(232,70,70,0.20);
    border-radius: 10px; padding: 1px 7px; flex-shrink: 0;
  }
  .kb-col-toggle { font-size: 10px; color: rgba(180,40,40,0.35); flex-shrink: 0; }

  /* ── In Test Cell: blue accent (matches wave button colour) ── */
  #kb-sub-intest .kb-col-header {
    border-color: rgba(26,111,212,0.22);
    background: rgba(26,111,212,0.07);
  }
  #kb-sub-intest .kb-col-header:hover { background: rgba(26,111,212,0.13); }
  #kb-sub-intest .kb-col-icon  { color: rgba(26,111,212,0.70); }
  #kb-sub-intest .kb-col-title { color: rgba(26,111,212,0.85); }
  #kb-sub-intest .kb-col-count {
    color: rgba(26,111,212,0.70);
    background: rgba(26,111,212,0.10);
    border-color: rgba(26,111,212,0.25);
  }
  #kb-sub-intest .kb-col-toggle { color: rgba(26,111,212,0.40); }
  #kb-sub-intest .kb-col-header { border-color: rgba(26,111,212,0.22); }
  #kb-sub-intest .kb-cards { border-color: rgba(26,111,212,0.22); }
  #kb-cards-intest .kb-card {
    background: rgba(26,111,212,0.06);
    border-color: rgba(26,111,212,0.22);
  }
  #kb-cards-intest .kb-card:hover { background: rgba(26,111,212,0.12); }
  #kb-cards-intest .kb-card.kb-card-selected {
    background: rgba(26,111,212,0.13);
    border-color: rgba(26,111,212,0.50);
    box-shadow: inset 2px 0 0 #1a6fd4;
  }
  #kb-cards-intest .kb-card-engine { color: rgba(20,80,180,0.90); }
  #kb-cards-intest .kb-card-esn,
  #kb-cards-intest .kb-card-wo,
  #kb-cards-intest .kb-card-engineer,
  #kb-cards-intest .kb-card-date { color: rgba(26,111,212,0.55); }
  #kb-cards-intest .kb-card-customer { color: rgba(20,80,180,0.70); }

  /* ── Card list ── */
  .kb-cards {
    border: 1px solid rgba(232,70,70,0.18);
    border-top: none;
    padding: 8px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    min-height: 60px;
    flex: 1;
    overflow-y: auto;
    min-height: 0;
  }
  .kb-cards::-webkit-scrollbar { width: 4px; }
  .kb-cards::-webkit-scrollbar-thumb { background: rgba(232,70,70,0.25); border-radius: 2px; }
  .kb-cards::-webkit-scrollbar-track { background: transparent; }

  /* ── Split column (InTest / OnHold) ── */
  .kb-col-split {
    flex: 1;
    min-width: 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    gap: 10px;
    position: relative;
    overflow: hidden;
  }
  .kb-col-split.collapsed {
    flex: 0 0 40px;
    cursor: pointer;
    border: 1px solid rgba(232,70,70,0.18);
    background: rgba(232,70,70,0.04);
  }
  .kb-col-split.collapsed:hover { background: rgba(232,70,70,0.09); }
  .kb-col-split.collapsed > * { display: none; }
  .kb-col-split.collapsed::after {
    content: attr(data-label);
    display: block;
    position: absolute;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%) rotate(-90deg);
    white-space: nowrap;
    font-size: 10px; font-weight: 700; letter-spacing: 0.14em;
    text-transform: uppercase; color: rgba(180,40,40,0.60);
  }
  .kb-subcol { display: flex; flex-direction: column; }
  .kb-subcol-grow { flex: 1; display: flex; flex-direction: column; }
  .kb-subcol-grow .kb-cards { flex: 1; }
  /* fixed-height top section (InTest) */
  .kb-subcol-fixed .kb-cards { min-height: 120px; max-height: 260px; overflow-y: auto; }

  /* ── Sortable ghost ── */
  .kb-sortable-ghost {
    opacity: 0.30;
    background: rgba(232,70,70,0.08);
    border: 1px dashed rgba(232,70,70,0.35);
  }

  /* ── Card ── */
  .kb-card {
    background: rgba(255,255,255,0.45);
    border: 1px solid rgba(232,70,70,0.18);
    padding: 10px 12px;
    cursor: grab;
    display: flex;
    flex-direction: column;
    gap: 4px;
    transition: background 120ms;
  }
  .kb-card:hover { background: rgba(255,255,255,0.72); }
  .kb-card:active { cursor: grabbing; }
  .kb-card.kb-card-selected {
    background: rgba(232,70,70,0.10);
    border-color: rgba(232,70,70,0.45);
    box-shadow: inset 2px 0 0 #e84646;
  }

  .kb-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; }
  .kb-card-engine { font-size: 20px; font-weight: 700; color: rgba(140,30,30,0.90); letter-spacing: -0.01em; }
  .kb-card-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex-shrink: 0; }
  .kb-card-esn { font-size: 11px; color: rgba(180,40,40,0.50); font-weight: 500; }
  .kb-card-wo { font-size: 11px; color: rgba(180,40,40,0.55); letter-spacing: 0.02em; }
  .kb-card-customer { font-size: 13px; color: rgba(140,30,30,0.65); }
  .kb-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; gap: 6px; }
  .kb-card-footer-left { display: flex; flex-direction: column; gap: 2px; }
  .kb-card-engineer { font-size: 12px; color: rgba(180,40,40,0.50); }
  .kb-card-date { font-size: 12px; color: rgba(180,40,40,0.50); }

  /* ── Progress circle ── */
  .kb-progress { width: 30px; height: 30px; flex-shrink: 0; }
  .kb-progress-bg { fill: none; stroke: rgba(232,70,70,0.12); stroke-width: 2.5; }
  .kb-progress-fg {
    fill: none; stroke: #e84646; stroke-width: 2.5;
    stroke-dasharray: 56.55; stroke-linecap: round;
    transform: rotate(-90deg); transform-origin: 12px 12px;
    transition: stroke-dashoffset 400ms ease;
  }
  .kb-progress-txt {
    font-size: 5px; fill: rgba(180,40,40,0.70); font-weight: 700;
    text-anchor: middle; dominant-baseline: middle; font-family: inherit;
  }
</style>

<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
function kbToggle(col) {
  document.getElementById('kb-col-' + col).classList.toggle('collapsed');
}

// ── Detail panel ──
let _kbDragging = false;
const KB_ALL_COLS = ['shop','planned','split','paperwork','tested'];

function kbCollapseAll() {
  KB_ALL_COLS.forEach(col => document.getElementById('kb-col-'+col)?.classList.add('collapsed'));
}

// ── Card factory (used by kbRenderBoard + SAP init) ──────��───────
function createKanbanCard(pkg) {
  const card = document.createElement('div');
  card.className = 'kb-card';
  card.dataset.esn        = pkg.esn        || '';
  card.dataset.workorder  = pkg.wo         || '';
  card.dataset.engineType = pkg.kb_engine_type || pkg.template || '';
  card.dataset.sapEngine  = pkg.sap_engine || '';
  card.dataset.customer   = pkg.kb_customer|| '';
  card.dataset.engineer   = pkg.kb_engineer|| '';
  card.dataset.rating     = pkg.kb_rating  || '';
  card.dataset.kbStatus   = pkg.kb_status  || 'planned';
  const dates = pkg.kb_dates || {};
  card.dataset.b3  = dates.b3  || '';
  card.dataset.ts  = dates.ts  || '';
  card.dataset.te  = dates.te  || '';
  card.dataset.pre = dates.pre || '';
  card.dataset.preservation = dates.preservation || '';
  card.onclick = () => kbOpenCard(card);
  const _cardTpl = _pfTemplates.find(t => t.engine_type === (pkg.kb_engine_type || pkg.template));
  applyCardColor(card, _cardTpl?.color);

  // Date label by status
  const statusDateMap = {
    planned: ['Pre-Test-Meeting', dates.pre || ''],
    intest:  ['Test Start',       dates.ts  || ''],
    onhold:  ['Test Start',       dates.ts  || ''],
    paperwork: ['Preservation',   dates.preservation || ''],
    tested:  ['Preservation',     dates.preservation || ''],
    shop:    ['Shop Finish',       dates.b3  || ''],
  };
  const [dateLabel, dateValue] = statusDateMap[pkg.kb_status || 'planned'] || ['Date', ''];

  card.innerHTML = `
    <div class="kb-card-top">
      <span class="kb-card-engine">${card.dataset.sapEngine || card.dataset.engineType}</span>
      <div class="kb-card-right">
        <span class="kb-card-esn">ESN ${card.dataset.esn}</span>
        <span class="kb-card-wo">WO ${card.dataset.workorder}</span>
      </div>
    </div>
    <div class="kb-card-customer">${card.dataset.customer || '—'}</div>
    <div class="kb-card-footer">
      <div class="kb-card-footer-left">
        <span class="kb-card-engineer">${card.dataset.engineer ? '◈ '+card.dataset.engineer : '◈ —'}</span>
      </div>
      <svg class="kb-progress" viewBox="0 0 24 24">
        <circle class="kb-progress-bg" cx="12" cy="12" r="9"/>
        <circle class="kb-progress-fg" cx="12" cy="12" r="9" style="stroke-dashoffset:56.55"/>
        <text class="kb-progress-txt" x="12" y="12">0%</text>
      </svg>
    </div>`;
  return card;
}

// ── Render board from instance list ──────────────────────────────
function kbRenderBoard(instances) {
  const cols = ['planned','intest','onhold','paperwork','tested'];
  cols.forEach(col => { document.getElementById('kb-cards-'+col).innerHTML = ''; });
  instances.forEach(pkg => {
    const status = pkg.kb_status || 'planned';
    if (!cols.includes(status)) return;
    const card = createKanbanCard(pkg);
    document.getElementById('kb-cards-'+status).appendChild(card);
  });
  kbUpdateCounts();
  // Fortschritt für bereits gecachte Instanzen sofort setzen
  instances.forEach(pkg => {
    const key  = pkg.esn+'__'+pkg.wo;
    const inst = _pfInstanceData[key];
    if (!inst) return;
    const tpl = inst.lanes_snapshot
      ? {lanes: inst.lanes_snapshot}
      : _pfTemplates.find(t => t.engine_type === inst.template);
    if (tpl) kbUpdateCardProgress(pkg.esn, pkg.wo, pfCalcProgress(inst, tpl));
  });
}

function kbOpenCard(cardEl) {
  if (_kbDragging) return;
  document.querySelectorAll('.kb-card.kb-card-selected').forEach(c => c.classList.remove('kb-card-selected'));
  cardEl.classList.add('kb-card-selected');
  const engine   = cardEl.dataset.engineType || '';
  const esn      = cardEl.dataset.esn        || '';
  const wo       = cardEl.dataset.workorder  || '';
  const customer = cardEl.dataset.customer   || '';
  const engineer = cardEl.dataset.engineer   || '';
  const pctTxt   = cardEl.querySelector('.kb-progress-txt')?.textContent || '0%';
  const offset   = cardEl.querySelector('.kb-progress-fg')?.style.strokeDashoffset || '56.55';
  const colId    = cardEl.closest('.kb-cards')?.id?.replace('kb-cards-','') || '';
  const labels   = {shop:'Shop',planned:'Planned',intest:'In Test Cell',onhold:'On Hold',paperwork:'PaperWork',tested:'Tested'};
  const dates    = cardEl.dataset;
  const dateLabel_map = {planned:'Pre-Test-Meeting',intest:'Test Start',onhold:'Test Start',paperwork:'Preservation',tested:'Preservation',shop:'Shop Finish'};
  const dateVal_map   = {planned:dates.pre,intest:dates.ts,onhold:dates.ts,paperwork:dates.preservation,tested:dates.preservation,shop:dates.b3};
  const dateDisplay   = (dateVal_map[colId] || '') || (dates.b3 || '—');
  document.getElementById('kb-detail-engine').textContent   = engine;
  document.getElementById('kb-detail-status').textContent   = labels[colId] || colId;
  document.getElementById('kb-detail-esn').textContent      = esn;
  document.getElementById('kb-detail-wo').textContent       = wo;
  document.getElementById('kb-detail-customer').textContent     = customer;
  document.getElementById('kb-detail-rating').textContent       = cardEl.dataset.rating       || '—';
  document.getElementById('kb-detail-preservation').textContent = cardEl.dataset.preservation || '—';
  document.getElementById('kb-detail-engineer').textContent     = engineer || '—';
  document.getElementById('kb-detail-pct').textContent      = pctTxt;
  document.getElementById('kb-detail-pfg').style.strokeDashoffset = offset;
  kbCollapseAll();
  document.getElementById('kb-detail').classList.add('open', 'wide');
  kbLoadDetailPf(esn, wo, engine);
  kbLoadDocuments(wo, esn, engine, cardEl.dataset.preservation || '');
  kbRenderFolderDots(engine);
}

function kbRenderFolderDots(engine) {
  const el = document.getElementById('kb-detail-folders');
  if (!el) return;
  const tpl = _pfTemplates.find(t => t.engine_type === engine);
  const folders = (tpl?.folders || []).filter(f => f.path && f.path !== '');
  el.innerHTML = folders.map(f => `
    <button class="kb-ampel-item kb-folder-item"
      onclick="kbOpenFolder('${f.key}')"
      onmouseenter="kbResolveTooltip(this,'${f.key}')"
      title="${f.label}">
      <span class="kb-ampel-dot kb-folder-dot" id="kb-fdot-${f.key}"></span>
      <span class="kb-action-label">${f.label}</span>
    </button>`).join('');
}

async function kbOpenFolder(key) {
  const engine = document.getElementById('kb-detail-engine')?.textContent.trim() || '';
  const esn    = document.getElementById('kb-detail-esn')?.textContent.trim()    || '';
  const wo     = document.getElementById('kb-detail-wo')?.textContent.trim()     || '';
  const dot    = document.getElementById('kb-fdot-' + key);
  if (dot) dot.classList.add('doc-loading');
  try {
    const r = await fetch(`/api/kb/open_folder/${encodeURIComponent(key)}/${encodeURIComponent(engine)}/${encodeURIComponent(esn)}/${encodeURIComponent(wo)}`);
    const d = await r.json();
    if (dot) dot.className = 'kb-ampel-dot kb-folder-dot ' + (d.ok ? 'doc-found' : 'doc-missing');
    setTimeout(() => { if (dot) dot.className = 'kb-ampel-dot kb-folder-dot'; }, 1800);
  } catch(e) {
    if (dot) dot.className = 'kb-ampel-dot kb-folder-dot doc-missing';
  }
}

async function kbResolveTooltip(btn, key) {
  if (btn.dataset.pathCached) return;
  const engine = document.getElementById('kb-detail-engine')?.textContent.trim() || '';
  const esn    = document.getElementById('kb-detail-esn')?.textContent.trim()    || '';
  const wo     = document.getElementById('kb-detail-wo')?.textContent.trim()     || '';
  try {
    const r = await fetch(`/api/kb/resolve_folder/${encodeURIComponent(key)}/${encodeURIComponent(engine)}/${encodeURIComponent(esn)}/${encodeURIComponent(wo)}`);
    const d = await r.json();
    btn.title = d.found ? d.path : (d.message || 'Nicht gefunden');
    btn.dataset.pathCached = '1';
  } catch(e) { /* silent */ }
}

async function kbLoadDetailPf(esn, wo, engine) {
  const pfEl = document.getElementById('kb-detail-pf'); if (!pfEl) return;
  const key = esn+'__'+wo;
  if (!_pfInstanceData[key]) {
    const d = await fetch('/api/process/instances/'+esn+'/'+wo).then(r=>r.json()).catch(()=>null);
    if (d && d.esn) _pfInstanceData[key] = d;
  }
  const inst = _pfInstanceData[key];
  if (!inst) {
    if (_kbPendingTemplate && _kbPendingTemplate.esn === esn && _kbPendingTemplate.wo === wo) {
      const p = _kbPendingTemplate;
      const opts = _pfTemplates.map(t =>
        `<option value="${t.engine_type}"${t.engine_type===p.tplName?' selected':''}>${t.engine_type}</option>`
      ).join('');
      pfEl.innerHTML = `
        <div class="kb-tpl-confirm">
          <div class="kb-tpl-confirm-header">
            <span class="kb-tpl-confirm-icon">!</span>
            <span class="kb-tpl-confirm-label">Template zuweisen</span>
          </div>
          <select class="kb-tpl-confirm-sel" id="kb-tpl-confirm-sel">${opts}</select>
          <div class="kb-tpl-confirm-btns">
            <button class="kb-tpl-confirm-ok" onclick="kbConfirmPendingTemplate()">✓ Bestätigen</button>
            <button class="kb-tpl-confirm-cancel" onclick="kbCancelPendingTemplate()">✕ Abbrechen</button>
          </div>
        </div>`;
    } else {
      pfEl.innerHTML='<div class="kb-detail-pf-empty">Noch keine Prozessinstanz — Karte in „Planned" ziehen um zu starten.</div>';
    }
    return;
  }
  const prevKey = _pfActiveTab ? _pfActiveTab.esn+'__'+_pfActiveTab.wo : null;
  _pfActiveTab = {esn, wo};
  if (prevKey !== key) { _pfOpenPhases.clear(); _pfPhasesInitialized = false; }
  if (!_pfInstances.find(i=>i.esn===esn&&i.wo===wo))
    _pfInstances.push({esn, wo, template:inst.template, label:inst.label||''});
  const tplData = inst.lanes_snapshot
    ? {lanes:inst.lanes_snapshot, milestones:inst.milestones_snapshot||[]}
    : _pfTemplates.find(t=>t.engine_type===inst.template);
  if (!tplData) {
    pfEl.innerHTML = pfRenderTplSelector(inst, true);
    return;
  }
  pfEl.innerHTML = pfRenderAccordion(inst, tplData);
  // Fortschritt nach Laden der Instanz auf Kachel + Detail-Panel setzen
  kbUpdateCardProgress(esn, wo, pfCalcProgress(inst, tplData));
}

async function kbConfirmPendingTemplate() {
  const p = _kbPendingTemplate; if (!p) return;
  const sel = document.getElementById('kb-tpl-confirm-sel');
  const chosenTpl = sel ? sel.value : p.tplName;
  _kbPendingTemplate = null;
  const card = document.querySelector('.kb-card.kb-card-selected');
  if (card) card.dataset.engineType = chosenTpl;
  await pfCreateInstanceData(p.esn, p.wo, chosenTpl, p.kbExtra);
  kbLoadDetailPf(p.esn, p.wo, chosenTpl);
  kbLoadDocuments(p.wo, p.esn, chosenTpl, '');
  kbRenderFolderDots(chosenTpl);
}

function kbCancelPendingTemplate() {
  const p = _kbPendingTemplate; if (!p) return;
  _kbPendingTemplate = null;
  const card = document.querySelector('.kb-card.kb-card-selected');
  if (card) { card.remove(); kbUpdateCounts(); }
  kbCloseDetail();
}

function pfRenderTplSelector(inst, missingTpl) {
  const esn = inst.esn; const wo = inst.wo; const cur = inst.template||'—';
  const opts = _pfTemplates.map(t =>
    `<option value="${t.engine_type}"${t.engine_type===cur?' selected':''}>${t.engine_type}</option>`
  ).join('');
  const noMatchNote = missingTpl
    ? `<div class="pfa-tpl-missing">Kein Template für „${cur}" gefunden.</div>` : '';
  // Static row (always visible); changer row (hidden until "Ändern" clicked)
  return `${noMatchNote}
    <div class="pfa-tpl-row" id="pfa-tpl-static">
      <span class="pfa-tpl-row-label">Template</span>
      <span class="pfa-tpl-current">${cur}</span>
      <button class="pfa-edit-btn" onclick="pfShowTplChanger()" title="Template wechseln">✎ Ändern</button>
    </div>
    <div class="pfa-tpl-row" id="pfa-tpl-changer" style="display:${missingTpl?'':'none'}">
      <span class="pfa-tpl-row-label">Neu</span>
      <select class="pfa-tpl-select" id="pfa-tpl-new-sel">
        ${_pfTemplates.length ? opts : '<option value="">— keine Templates vorhanden —</option>'}
      </select>
      <button class="pfa-edit-btn" onclick="pfConfirmReassign('${esn}','${wo}','${cur}')">✓</button>
      <button class="pfa-edit-btn" onclick="pfHideTplChanger()">✕</button>
    </div>`;
}

function pfShowTplChanger() {
  document.getElementById('pfa-tpl-static').style.display  = 'none';
  document.getElementById('pfa-tpl-changer').style.display = '';
}
function pfHideTplChanger() {
  document.getElementById('pfa-tpl-static').style.display  = '';
  document.getElementById('pfa-tpl-changer').style.display = 'none';
}
async function pfConfirmReassign(esn, wo, oldTpl) {
  const newTpl = document.getElementById('pfa-tpl-new-sel')?.value;
  if (!newTpl || newTpl === oldTpl) { pfHideTplChanger(); return; }
  if (!confirm(
    'Template von „' + oldTpl + '" auf „' + newTpl + '" ändern?\n\n' +
    'Achtung: Der gesamte bisherige Fortschritt (Step-Status) geht dabei verloren!'
  )) return;
  await pfReassignTemplate(esn, wo, newTpl);
}

function pfRenderAccordion(inst, tplData) {
  const ss = inst.step_status||{};
  const ICONS = {pending:'◻', running:'⟳', done:'✓', error:'✗', skipped:'○'};
  const editBtn = `<button class="pfa-edit-btn${_pfInstanceEditMode?' active':''}" id="pf-edit-inst-btn"
    onclick="pfToggleInstanceEdit()">✎ Instanz</button>`;
  let html = '';

  (tplData.lanes||[]).forEach(lane => {
    const allSteps = [...(lane.procedures||[]).flatMap(p=>p.steps||[]),
                     ...(inst.extra_steps||[]).filter(s=>s.lane_id===lane.id)];
    const doneCount = allSteps.filter(s=>(ss[s.id]||{}).status==='done').length;
    const allDone   = allSteps.length > 0 && doneCount === allSteps.length;
    const progress  = allSteps.length ? `${doneCount}/${allSteps.length}` : '';

    html += `<div class="pfa-phase-heading">
      <span class="pfa-phase-icon">${lane.icon||'◻'}</span>
      <span class="pfa-phase-label">${lane.label||''}</span>
      <span class="pfa-phase-prog${allDone?' done':''}">${progress}${allDone?' ✓':''}</span>
    </div>`;

    (lane.procedures||[]).forEach(proc =>
      (proc.steps||[]).forEach(s => { html += pfRenderFlatStep(s, ss[s.id]||{}, false, ICONS); })
    );
    (inst.extra_steps||[]).filter(s=>s.lane_id===lane.id).forEach(s => {
      html += pfRenderFlatStep(s, ss[s.id]||{}, true, ICONS);
    });
    if (_pfInstanceEditMode)
      html += `<div class="pfa-add-step"><button class="pfa-add-step-btn" onclick="pfAddExtraStep('${lane.id}')">+ Instanz-Step</button></div>`;
  });

  html += `<div class="pfa-header" style="margin-top:18px">
    <div></div>${editBtn}</div>
    ${pfRenderTplSelector(inst, false)}`;
  return html;
}

function pfRenderFlatStep(step, status, isExtra, ICONS) {
  const st       = status.status||'pending';
  const icon     = ICONS[st]||'◻';
  const labelCls = (st==='done'||st==='skipped') ? st : '';
  const extraMark= isExtra ? '<span style="opacity:.35;font-size:9px;margin-left:3px">◈</span>' : '';
  const ts       = status.done_at||status.started_at
    ? `<div class="pfa-step-ts">${status.done_at||status.started_at}</div>` : '';
  const typeIcon = step.type==='flow_trigger'?'⬢':(step.type==='hb_status'?'⇡':'');

  let actions = '';
  if (_pfInstanceEditMode) {
    actions = `<div class="pfa-step-edit">
      <input class="pf-step-edit-input" value="${step.label||''}" placeholder="Label"
        onchange="pfEditStepLabel('${step.id}',this.value)" onclick="event.stopPropagation()">
      <select class="pf-step-edit-sel" onchange="pfEditStepType('${step.id}',this.value)" onclick="event.stopPropagation()">
        <option value="manual"${step.type==='manual'?' selected':''}>◻ Manual</option>
        <option value="flow_trigger"${step.type==='flow_trigger'?' selected':''}>⬢ Flow Trigger</option>
        <option value="hb_status"${step.type==='hb_status'?' selected':''}>⇡ HB Status</option>
      </select>
      ${step.type==='flow_trigger' ? pfFlowSelectHtml(step.id, step.linked_flow||'') : ''}
      <button class="pfa-btn" style="border-color:#e84646;color:#991b1b" onclick="event.stopPropagation();pfDelStep('${step.id}')">🗑</button>
    </div>`;
  } else {
    // Always-visible actions based on current state
    if (st==='pending'||st==='error') {
      actions += `<button class="pfa-btn start" onclick="pfSetStatus('${step.id}','running')">▶ Start</button>`;
      actions += `<button class="pfa-btn done"  onclick="pfSetStatus('${step.id}','done')">✓ Done</button>`;
      actions += `<button class="pfa-btn"        onclick="pfSetStatus('${step.id}','skipped')">— Skip</button>`;
    }
    if (st==='running') {
      actions += `<button class="pfa-btn done" onclick="pfSetStatus('${step.id}','done')">✓ Done</button>`;
      actions += `<button class="pfa-btn"       onclick="pfSetStatus('${step.id}','error')">✗ Error</button>`;
      actions += `<button class="pfa-btn reset" onclick="pfSetStatus('${step.id}','pending')">↩</button>`;
    }
    if (st==='done'||st==='skipped')
      actions += `<button class="pfa-btn reset" onclick="pfSetStatus('${step.id}','pending')">↩</button>`;
    if (step.type==='flow_trigger'&&step.linked_flow)
      actions += `<button class="pfa-btn flow" onclick="pfTriggerFlow('${step.id}','${step.linked_flow}')">⬢ Flow</button>`;
  }

  const lr = status.last_run;
  const lastRunHtml = (step.type==='flow_trigger' && lr)
    ? `<div class="pfa-step-last-run ${lr.status==='done'?'ok':(lr.status==='running'?'err':'')}">${lr.status==='done'?'✓':lr.status==='running'?'✗':'○'} ${lr.ts}</div>`
    : '';

  return `<div class="pfa-step st-${st}" id="pfas-${step.id}">
    <span class="pfa-step-icon ${st}">${icon}</span>
    <div class="pfa-step-body">
      <div class="pfa-step-row1">
        <span class="pfa-step-label ${labelCls}">${step.label||''}${extraMark}</span>
        ${typeIcon?`<span class="pfa-step-type-icon">${typeIcon}</span>`:''}
      </div>
      ${ts}${lastRunHtml}
      <div class="pfa-step-actions">${actions}</div>
    </div>
  </div>`;
}

function kbCloseDetail() {
  const detail = document.getElementById('kb-detail');
  detail.classList.remove('open', 'wide');
  document.querySelectorAll('.kb-card.kb-card-selected').forEach(c => c.classList.remove('kb-card-selected'));
  ['shop','tested'].forEach(col => document.getElementById('kb-col-'+col)?.classList.add('collapsed'));
  ['planned','split','paperwork'].forEach(col => document.getElementById('kb-col-'+col)?.classList.remove('collapsed'));
  kbCloseActionPanel();
}

function kbToggleActionPanel(key) {
  const wrap  = document.getElementById('kb-action-wrap');
  const panel = document.getElementById('kb-action-panel');
  const inner = document.getElementById('kb-action-panel-inner');
  const btn   = document.getElementById('kb-action-btn-' + key);
  const isOpen = wrap.classList.contains('panel-open') && panel.dataset.activeKey === key;
  if (isOpen) {
    kbCloseActionPanel();
  } else {
    inner.innerHTML = kbGetActionPanelContent(key);
    // measure natural width, then animate from 0
    panel.style.transition = 'none';
    panel.style.width = 'auto';
    const w = panel.scrollWidth;
    panel.style.width = '0';
    panel.offsetWidth; // force reflow
    panel.style.transition = '';
    panel.style.width = w + 'px';
    wrap.classList.add('panel-open');
    panel.dataset.activeKey = key;
    document.querySelectorAll('.kb-action-icon').forEach(b => b.classList.remove('active'));
    btn?.classList.add('active');
  }
}

function kbCloseActionPanel() {
  const wrap  = document.getElementById('kb-action-wrap');
  const panel = document.getElementById('kb-action-panel');
  if (!wrap) return;
  panel.style.width = '0';
  wrap.classList.remove('panel-open');
  panel.dataset.activeKey = '';
  document.querySelectorAll('.kb-action-icon').forEach(b => b.classList.remove('active'));
}

function kbGetActionPanelContent(key) {
  if (key === 'namen') {
    const esn = document.getElementById('kb-detail-esn')?.textContent.trim() || 'ESN';
    const wo  = (document.getElementById('kb-detail-wo')?.textContent.trim() || 'WO').replace(/\./g, '');
    return [`${esn}_${wo}_Testresults`, `${esn}_${wo}_Batch_01`, `${esn}_${wo}_Batch_02`]
      .map(n => `<div class="kb-aname-row" onclick="kbCopyName('${n}',this)">
        <span class="kb-aname-text">${n}</span>
      </div>`).join('');
  }
  if (key === 'email') {
    const engine    = document.getElementById('kb-detail-engine')?.textContent.trim() || '';
    const procTpl   = _pfTemplates.find(t => t.engine_type === engine) || {};
    const emailTpls = procTpl.email_templates || [];
    if (!emailTpls.length)
      return `<div class="kb-aname-row" style="cursor:default"><span class="kb-aname-label" style="opacity:0.4">Keine Templates konfiguriert</span></div>`;
    return emailTpls.map(t =>
      `<div class="kb-aname-row" onclick="kbSendEmail('${t.file}',this)">
        <span class="kb-aname-label">${t.label || t.file}</span>
      </div>`
    ).join('');
  }
  return '';
}

async function kbSendEmail(templateFile, el) {
  const row      = (el?.closest('.kb-aname-row')) || el;
  const esn          = document.getElementById('kb-detail-esn')?.textContent.trim()          || '';
  const wo           = document.getElementById('kb-detail-wo')?.textContent.trim()           || '';
  const engine       = document.getElementById('kb-detail-engine')?.textContent.trim()       || '';
  const customer     = document.getElementById('kb-detail-customer')?.textContent.trim()     || '';
  const rating       = document.getElementById('kb-detail-rating')?.textContent.trim().replace('—','')       || '';
  const preservation = document.getElementById('kb-detail-preservation')?.textContent.trim().replace('—','') || '';
  if (row) row.classList.add('flash-run');
  try {
    const res  = await fetch('/api/kb/email', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ wo, engine_type: engine, esn, customer, rating, preservation, template_file: templateFile })
    });
    const data = await res.json();
    if (row) {
      row.classList.remove('flash-run');
      row.classList.add(data.ok ? 'flash-ok' : 'flash-err');
      setTimeout(() => row.classList.remove('flash-ok', 'flash-err'), 900);
    }
    if (!data.ok) alert('Fehler: ' + (data.reason || 'Unbekannt'));
  } catch(e) {
    if (row) { row.classList.remove('flash-run'); row.classList.add('flash-err'); setTimeout(() => row.classList.remove('flash-err'), 900); }
    alert('Netzwerkfehler: ' + e);
  }
}

function kbCopyName(text, el) {
  const row = el.closest('.kb-aname-row') || el;
  navigator.clipboard.writeText(text).then(() => {
    row.classList.add('flash-ok');
    setTimeout(() => row.classList.remove('flash-ok'), 700);
  });
}

function kbMetaEditStart(field, el) {
  const prev = el.textContent === '—' ? '' : el.textContent;
  const esn  = document.getElementById('kb-detail-esn').textContent.trim();
  const wo   = document.getElementById('kb-detail-wo').textContent.trim();
  if (field === 'engineer') { kbEngineerEditStart(prev, esn, wo, el); return; }
  const inp  = document.createElement('input');
  inp.className = 'kb-meta-input';
  inp.type  = field === 'preservation' ? 'date' : 'text';
  if (field === 'preservation') {
    inp.value = prev || new Date().toISOString().split('T')[0];
    inp.onchange  = () => kbMetaSave(field, inp.value, esn, wo, el);
  } else {
    inp.value = prev;
  }
  inp.onblur    = () => kbMetaSave(field, inp.value.trim(), esn, wo, el);
  inp.onkeydown = e => { if (e.key === 'Enter') inp.blur();
                         if (e.key === 'Escape') { el.textContent = prev || '—'; } };
  el.textContent = '';
  el.appendChild(inp);
  inp.focus();
}

let _kbEngineerList = null;

async function kbEngineerEditStart(prev, esn, wo, el) {
  if (!_kbEngineerList) {
    const ab = await fetch('/api/address_book').then(r => r.json()).catch(() => ({ engineers: [] }));
    _kbEngineerList = (ab.engineers || []).slice().sort();
  }

  const wrap = document.createElement('div');
  wrap.className = 'kb-eng-wrap';

  const inp = document.createElement('input');
  inp.className = 'kb-meta-input';
  inp.value       = prev;
  inp.placeholder = 'Name...';
  inp.autocomplete = 'off';

  const drop = document.createElement('div');
  drop.className = 'kb-eng-drop';

  function renderDrop(query) {
    drop.innerHTML = '';
    const q = query.toLowerCase();
    const matches = _kbEngineerList.filter(n => n.toLowerCase().includes(q));
    if (matches.length === 0) {
      if (q.length > 0) {
        const d = document.createElement('div');
        d.className = 'kb-eng-drop-item kb-eng-drop-add';
        d.textContent = '+ "' + query + '" verwenden';
        d.onmousedown = e => { e.preventDefault(); commit(query); };
        drop.appendChild(d);
      }
      return;
    }
    matches.forEach(name => {
      const d = document.createElement('div');
      d.className = 'kb-eng-drop-item';
      d.textContent = name;
      d.onmousedown = e => { e.preventDefault(); commit(name); };
      drop.appendChild(d);
    });
  }

  let committed = false;
  function commit(name) {
    if (committed) return;
    committed = true;
    el.textContent = name || '—';
    kbMetaSave('engineer', name, esn, wo, el);
  }

  inp.oninput   = () => renderDrop(inp.value);
  inp.onfocus   = () => { renderDrop(inp.value); drop.style.display = 'block'; };
  inp.onblur    = () => setTimeout(() => { drop.style.display = 'none'; commit(inp.value.trim()); }, 150);
  inp.onkeydown = e => {
    if (e.key === 'Enter')  { inp.blur(); }
    if (e.key === 'Escape') { el.textContent = prev || '—'; }
    if (e.key === 'ArrowDown') {
      const items = drop.querySelectorAll('.kb-eng-drop-item');
      if (items.length) { e.preventDefault(); items[0].focus(); }
    }
  };

  el.textContent = '';
  wrap.appendChild(inp);
  wrap.appendChild(drop);
  el.appendChild(wrap);
  renderDrop('');
  drop.style.display = 'block';
  inp.focus();
}

async function kbMetaSave(field, value, esn, wo, el) {
  el.textContent = value || '—';
  const key  = esn + '__' + wo;
  let inst   = _pfInstanceData[key];
  if (!inst) {
    inst = await fetch('/api/process/instances/' + esn + '/' + wo).then(r => r.json()).catch(() => null);
    if (!inst || inst.error) return;
    _pfInstanceData[key] = inst;
  }
  if (field === 'rating') {
    inst.kb_rating = value;
    document.querySelectorAll('.kb-card').forEach(c => {
      if (c.dataset.esn === esn && c.dataset.workorder === wo) c.dataset.rating = value;
    });
  } else if (field === 'preservation') {
    if (!inst.kb_dates) inst.kb_dates = {};
    inst.kb_dates.preservation = value;
    document.querySelectorAll('.kb-card').forEach(c => {
      if (c.dataset.esn === esn && c.dataset.workorder === wo) c.dataset.preservation = value;
    });
  } else if (field === 'engineer') {
    inst.kb_engineer = value;
    document.querySelectorAll('.kb-card').forEach(c => {
      if (c.dataset.esn === esn && c.dataset.workorder === wo) c.dataset.engineer = value;
    });
  }
  fetch('/api/process/instances/' + esn + '/' + wo, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(inst)
  });
}

const _kbDocState = {};
let _kbPendingTemplate = null;

async function kbLoadDocuments(wo, esn, engineType, preservation) {
  const keys = ['testarchiv', 'wrb', 'service_order', 'slave'];
  keys.forEach(k => {
    const dot = document.getElementById('kb-doc-dot-' + k);
    if (dot) { dot.className = 'kb-ampel-dot doc-loading'; dot.title = ''; }
  });
  _kbDocState.wo = wo; _kbDocState.esn = esn;
  _kbDocState.engineType = engineType; _kbDocState.preservation = preservation;
  try {
    const params = new URLSearchParams({ wo, esn, engine_type: engineType });
    const data = await fetch('/api/kb/documents?' + params).then(r => r.json());
    keys.forEach(k => {
      const dot = document.getElementById('kb-doc-dot-' + k);
      if (!dot) return;
      const d = data[k] || {};
      dot.classList.remove('doc-loading');
      if (d.status === 'found') {
        dot.classList.add('doc-found');
        dot.title = k === 'testarchiv'
          ? ('Testarchiv vorhanden' + (d.file_date ? '  ·  ' + d.file_date : ''))
          : ((d.file_name || k) + (d.file_date ? '  ·  ' + d.file_date : ''));
      } else if (d.status === 'folder_only') {
        dot.classList.add('doc-folder');
        dot.title = 'Ordner gefunden — Datei fehlt';
      } else {
        dot.classList.add('doc-missing');
        dot.title = k === 'testarchiv' ? 'Klicken zum Anlegen' : 'Nicht gefunden';
      }
    });
  } catch(e) {
    keys.forEach(k => {
      const dot = document.getElementById('kb-doc-dot-' + k);
      if (dot) { dot.classList.remove('doc-loading'); dot.classList.add('doc-missing'); }
    });
  }
}

async function kbOpenDocument(docType) {
  const dot    = document.getElementById('kb-doc-dot-' + docType);
  const status = dot?.classList.contains('doc-found')   ? 'found'
               : dot?.classList.contains('doc-folder')  ? 'folder_only' : 'missing';

  if (docType === 'testarchiv' && status === 'missing') {
    if (dot) dot.className = 'kb-ampel-dot doc-loading';
    const r = await fetch('/api/kb/documents/open', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ wo: _kbDocState.wo, esn: _kbDocState.esn,
        engine_type: _kbDocState.engineType, doc_type: 'testarchiv',
        action: 'create' })
    });
    const d = await r.json();
    if (dot) dot.className = 'kb-ampel-dot ' + (d.ok ? 'doc-found' : 'doc-missing');
    if (d.ok && dot) dot.title = 'Testarchiv angelegt';
    return;
  }

  if (status === 'missing') return;
  const action = status === 'found' ? 'file' : 'folder';
  await fetch('/api/kb/documents/open', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ wo: _kbDocState.wo, esn: _kbDocState.esn,
      engine_type: _kbDocState.engineType, doc_type: docType, action })
  });
}

async function kbInjectToWave() {
  const esn    = document.getElementById('kb-detail-esn').textContent.trim();
  const wo     = document.getElementById('kb-detail-wo').textContent.trim();
  const engine = document.getElementById('kb-detail-engine').textContent.trim();
  if (!esn && !wo) return;
  const woNorm = pfNormalizeWo(wo);
  const recs   = (typeof recipes !== 'undefined' && recipes.length)
    ? recipes
    : await fetch('/api/recipes').then(r => r.json()).catch(() => []);
  const rec    = recs.find(r => engine.startsWith(r.engine_type) || r.engine_type.startsWith(engine));
  const project = rec?.project_filter || '';
  window.location.href = '/wave?' + new URLSearchParams({project, esn, wo: woNorm, engine}).toString();
}

async function kbDeleteCard() {
  const card = document.querySelector('.kb-card.kb-card-selected');
  if (!card) return;
  const esn    = card.dataset.esn       || '';
  const wo     = card.dataset.workorder || '';
  const engine = card.dataset.engineType|| '';
  const colId  = card.closest('.kb-cards')?.id?.replace('kb-cards-','') || '';

  // Shop cards have no instance file — just remove from DOM
  if (colId === 'shop') {
    if (!confirm(`Shop-Karte entfernen?\n${engine}  ESN ${esn}  WO ${wo}\n\nDie Karte erscheint beim nächsten Seitenladen wieder aus der SAP-CSV.`)) return;
    card.remove();
    kbUpdateCounts();
    kbCloseDetail();
    return;
  }

  if (!confirm(`Karte und Prozessinstanz löschen?\n${engine}  ESN ${esn}  WO ${wo}\n\nDieser Vorgang kann nicht rückgängig gemacht werden.`)) return;

  // Delete instance file on server
  if (esn && wo) {
    await fetch('/api/process/instances/'+esn+'/'+wo, {method:'DELETE'}).catch(()=>{});
    // Remove from in-memory caches
    delete _pfInstanceData[esn+'__'+wo];
    const idx = _pfInstances.findIndex(i=>i.esn===esn&&i.wo===wo);
    if (idx >= 0) _pfInstances.splice(idx, 1);
    if (_pfActiveTab?.esn===esn && _pfActiveTab?.wo===wo) _pfActiveTab = null;
  }

  card.remove();
  kbUpdateCounts();
  kbCloseDetail();
}

// Columns that collapse/expand (side columns only)
const KB_SIDE_COLS = ['shop', 'tested'];
let _kbOrigCollapsed = new Set();

function kbOnStart() {
  _kbDragging = true;
  // Expand all collapsed side columns so cards can be dropped there
  KB_SIDE_COLS.forEach(col => {
    const el = document.getElementById('kb-col-' + col);
    if (el && el.classList.contains('collapsed')) {
      _kbOrigCollapsed.add(col);
      el.classList.remove('collapsed');
    }
  });
}

function kbOnEnd(evt) {
  setTimeout(() => { _kbDragging = false; }, 80);
  kbUpdateCounts();
  const droppedIn = evt.to.id.replace('kb-cards-', '');
  const droppedFrom = evt.from.id.replace('kb-cards-', '');
  // Collapse back side columns that didn't receive the card
  _kbOrigCollapsed.forEach(col => {
    if (col !== droppedIn) document.getElementById('kb-col-'+col)?.classList.add('collapsed');
  });
  _kbOrigCollapsed.clear();

  const card = evt.item;
  const esn    = card.dataset.esn       || '';
  const wo     = card.dataset.workorder || '';
  const engine = card.dataset.engineType || '';
  const customer = card.dataset.customer || '';

  if (!esn || !wo) return;

  // Shop → Planned: open detail with template confirmation banner
  if (droppedFrom === 'shop' && droppedIn !== 'shop') {
    card.dataset.kbStatus = droppedIn;
    const sapName = card.dataset.sapEngine || engine;
    const matched = _pfTemplates.find(t => sapName.startsWith(t.engine_type))
                 || _pfTemplates.find(t => t.engine_type.startsWith(sapName));
    const tplName = matched ? matched.engine_type : (_pfTemplates[0]?.engine_type || '');
    card.dataset.engineType = tplName;
    _kbPendingTemplate = {
      esn, wo, tplName,
      kbExtra: {
        kb_customer: customer,
        kb_dates:    { b3: card.dataset.b3||'', ts: card.dataset.ts||'', te: card.dataset.te||'' },
        sap_engine:  sapName,
      }
    };
    kbOpenCard(card);
    return;
  }

  // Between non-shop columns: update kb_status in instance JSON
  if (droppedFrom !== 'shop' && droppedIn !== 'shop' && droppedIn !== droppedFrom) {
    card.dataset.kbStatus = droppedIn;
    const key = esn+'__'+wo;
    const inst = _pfInstanceData[key];
    if (inst) {
      inst.kb_status = droppedIn;
      fetch('/api/process/instances/'+esn+'/'+wo,
        {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(inst)});
    } else {
      // Lazy-load then patch
      fetch('/api/process/instances/'+esn+'/'+wo).then(r=>r.json()).then(d=>{
        if (!d || d.error) return;
        d.kb_status = droppedIn;
        _pfInstanceData[key] = d;
        fetch('/api/process/instances/'+esn+'/'+wo,
          {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});
      });
    }
  }
}

const kbGroup = { name: 'kanban', pull: true, put: true };

Sortable.create(document.getElementById('kb-cards-shop'), {
  group: { name: 'kanban', pull: true, put: true },
  animation: 150, ghostClass: 'kb-sortable-ghost',
  onStart: kbOnStart, onEnd: kbOnEnd,
});
['planned','intest','onhold','paperwork','tested'].forEach(col => {
  Sortable.create(document.getElementById('kb-cards-' + col), {
    group: kbGroup, animation: 150, ghostClass: 'kb-sortable-ghost',
    onStart: kbOnStart, onEnd: kbOnEnd,
  });
});

function kbColSearch(col, query) {
  const q = query.trim().toLowerCase();
  document.getElementById('kb-cards-' + col).querySelectorAll('.kb-card').forEach(card => {
    const esn = (card.dataset.esn        || '').toLowerCase();
    const wo  = (card.dataset.workorder  || '').toLowerCase();
    card.classList.toggle('kb-hidden', q !== '' && !esn.includes(q) && !wo.includes(q));
  });
}

function kbUpdateCounts() {
  ['shop','planned','paperwork','tested'].forEach(col => {
    const n = document.getElementById('kb-cards-' + col).querySelectorAll('.kb-card').length;
    document.getElementById('kb-col-' + col).querySelector('.kb-col-count').textContent = n;
  });
  ['intest','onhold'].forEach(col => {
    const n = document.getElementById('kb-cards-' + col).querySelectorAll('.kb-card').length;
    document.getElementById('kb-sub-' + col).querySelector('.kb-col-count').textContent = n;
  });
}

// ════════════════════════════════════════════════════════════════
// TARDIS Browser
// ════════════════════════════════════════════════════════════════
let _kbtAllTests    = [];   // alle geladenen Tests (gefiltert nach Suche)
let _kbtShownCount  = 0;    // aktuell angezeigte Einträge
const KBT_PAGE_SIZE = 100;

// ── Extract ESN / WO from TARDIS test name ────────────────────
function _kbtParseTestName(name) {
  // ESN: reine 6-stellige Zahl ODER GA-Prefix (z.B. GA725123)
  const esnM = name.match(/\b(GA\d{4,8}|\d{6})\b/);
  // WO: einzelner Buchstabe + 4–6 Ziffern (z.B. L19830, A12345)
  const woM  = name.match(/\b([A-Z]\d{4,6})\b/);
  return {
    esn: esnM ? esnM[1] : '',
    wo:  woM  ? woM[1]  : '',
  };
}

function _kbtInBoard(esn, wo) {
  if (!esn && !wo) return false;
  return _pfInstances.some(i =>
    (esn && String(i.esn) === esn) ||
    (wo  && String(i.wo)  === wo)
  );
}

// ── Open / Close ──────────────────────────────────────────────
async function kbTardisOpen() {
  document.getElementById('kbt-modal').style.display   = 'flex';
  document.getElementById('kbt-backdrop').style.display = 'block';
  // Load projects (only once)
  const sel = document.getElementById('kbt-proj-sel');
  if (sel.options.length <= 1) {
    sel.innerHTML = '<option value="">⟳ Lade Projekte…</option>';
    try {
      const projs = await fetch('/api/tardis/projects').then(r=>r.json());
      sel.innerHTML = '<option value="">— Projekt wählen —</option>' +
        (projs.error ? '<option value="">Fehler beim Laden</option>'
          : projs.map(p=>`<option value="${p.name}">${p.name}</option>`).join(''));
    } catch(e) {
      sel.innerHTML = '<option value="">Verbindungsfehler</option>';
    }
  }
}
function kbTardisClose() {
  document.getElementById('kbt-modal').style.display    = 'none';
  document.getElementById('kbt-backdrop').style.display = 'none';
}

// ── Load pools after project selection ───────────────────────
async function kbTardisLoadPools() {
  // Pool is always 'engine' — just reset the list on project change
  document.getElementById('kbt-list').innerHTML = '';
  document.getElementById('kbt-count').textContent = '';
  document.getElementById('kbt-more-btn').style.display = 'none';
  _kbtAllTests = [];
}

// ── Load tests ────────────────────────────────────────────────
async function kbTardisLoadTests() {
  const proj = document.getElementById('kbt-proj-sel').value;
  const pool = 'engine';
  if (!proj) {
    document.getElementById('kbt-list').innerHTML =
      '<div class="kbt-empty">Bitte Projekt auswählen.</div>';
    return;
  }
  const btn  = document.getElementById('kbt-load-btn');
  btn.disabled = true; btn.textContent = '⟳ Lade…';
  document.getElementById('kbt-list').innerHTML =
    '<div class="kbt-empty">⟳ Lade Tests…</div>';
  document.getElementById('kbt-count').textContent = '';
  document.getElementById('kbt-more-btn').style.display = 'none';
  try {
    const tests = await fetch(
      `/api/tardis/tests?project=${encodeURIComponent(proj)}&pool=${encodeURIComponent(pool)}`
    ).then(r=>r.json());
    if (tests.error) throw new Error(tests.error);
    _kbtAllTests = tests;
    document.getElementById('kbt-search').value = '';
    kbTardisFilter();
  } catch(e) {
    document.getElementById('kbt-list').innerHTML =
      `<div class="kbt-empty">Fehler: ${e.message||e}</div>`;
  } finally {
    btn.disabled = false; btn.textContent = '▶ Laden';
  }
}

// ── Filter + render list ──────────────────────────────────────
function kbTardisFilter() {
  const q = (document.getElementById('kbt-search').value || '').toLowerCase();
  const filtered = q
    ? _kbtAllTests.filter(t =>
        (t.name||'').toLowerCase().includes(q) ||
        (t.description||'').toLowerCase().includes(q))
    : _kbtAllTests;
  _kbtShownCount = 0;
  _kbtRenderItems(filtered, true);
}

function _kbtRenderItems(tests, reset) {
  const list = document.getElementById('kbt-list');
  if (reset) { list.innerHTML = ''; _kbtShownCount = 0; }
  if (tests.length === 0) {
    list.innerHTML = '<div class="kbt-empty">Keine Tests gefunden.</div>';
    document.getElementById('kbt-count').textContent = '0 Tests';
    document.getElementById('kbt-more-btn').style.display = 'none';
    return;
  }

  const slice = tests.slice(_kbtShownCount, _kbtShownCount + KBT_PAGE_SIZE);
  _kbtShownCount += slice.length;

  slice.forEach(t => {
    const {esn, wo} = _kbtParseTestName(t.name || '');
    const inBoard   = _kbtInBoard(esn, wo);
    const item      = document.createElement('div');
    item.className  = 'kbt-item' + (inBoard ? ' in-board' : '');

    const metaParts = [];
    if (esn) metaParts.push(`ESN ${esn}`);
    if (wo)  metaParts.push(`WO ${wo}`);
    metaParts.push(`ID ${t.id}`);

    item.innerHTML = `
      <span class="kbt-item-dot">${inBoard ? '●' : '○'}</span>
      <div class="kbt-item-body">
        <div class="kbt-item-name">${t.name||'–'}</div>
        <div class="kbt-item-meta">${metaParts.join('  ·  ')}</div>
        ${inBoard ? '<div class="kbt-in-board-badge">● Im Board</div>' : ''}
      </div>
      ${!inBoard ? `<button class="kbt-add-btn">→ Karte</button>` : ''}`;

    if (!inBoard) {
      item.querySelector('.kbt-add-btn').addEventListener('click', e => {
        e.stopPropagation();
        kbTardisShowCreateForm(item, t.name||'', esn, wo);
      });
    }
    list.appendChild(item);
  });

  const total = (document.getElementById('kbt-search').value
    ? document.getElementById('kbt-list').querySelectorAll('.kbt-item').length
    : _kbtAllTests.length);
  document.getElementById('kbt-count').textContent =
    `${_kbtShownCount} / ${_kbtAllTests.length} Tests`;

  const moreBtn = document.getElementById('kbt-more-btn');
  // Store filtered list for "more" button
  moreBtn._filteredTests = tests;
  moreBtn.style.display = _kbtShownCount < tests.length ? 'block' : 'none';
}

function kbTardisShowMore() {
  const tests = document.getElementById('kbt-more-btn')._filteredTests || _kbtAllTests;
  _kbtRenderItems(tests, false);
}

// ── Create form (opens inline below the item) ─────────────────
function kbTardisShowCreateForm(itemEl, testName, esnGuess, woGuess) {
  // Close any other open forms
  document.querySelectorAll('.kbt-create-form').forEach(f => f.remove());
  document.querySelectorAll('.kbt-add-btn').forEach(b => b.textContent = '→ Karte');

  const btn = itemEl.querySelector('.kbt-add-btn');
  btn.textContent = '✕';
  btn.onclick = e => { e.stopPropagation(); form.remove(); btn.textContent = '→ Karte';
    btn.onclick = ev => { ev.stopPropagation(); kbTardisShowCreateForm(itemEl, testName, esnGuess, woGuess); }; };

  const tplOpts = _pfTemplates.map(t =>
    `<option value="${t.engine_type}">${t.engine_type}</option>`).join('');

  const form = document.createElement('div');
  form.className = 'kbt-create-form';
  form.innerHTML = `
    <span class="kbt-form-label">ESN</span>
    <input class="kbt-inp" id="kbt-f-esn" value="${esnGuess}" placeholder="ESN">
    <span class="kbt-form-label">WO</span>
    <input class="kbt-inp" id="kbt-f-wo"  value="${woGuess}"  placeholder="WO">
    <span class="kbt-form-label">Template</span>
    <select class="kbt-tpl-sel" id="kbt-f-tpl">${tplOpts||'<option value="">—</option>'}</select>
    <button class="kbt-confirm-btn" id="kbt-f-ok">✓ Erstellen</button>
    <button class="kbt-cancel-btn" id="kbt-f-cancel">✕</button>`;

  itemEl.querySelector('.kbt-item-body').appendChild(form);

  form.querySelector('#kbt-f-cancel').addEventListener('click', e => {
    e.stopPropagation(); form.remove();
    btn.textContent = '→ Karte';
    btn.onclick = ev => { ev.stopPropagation(); kbTardisShowCreateForm(itemEl, testName, esnGuess, woGuess); };
  });
  form.querySelector('#kbt-f-ok').addEventListener('click', async e => {
    e.stopPropagation();
    const esn = form.querySelector('#kbt-f-esn').value.trim();
    const wo  = form.querySelector('#kbt-f-wo').value.trim();
    const tpl = form.querySelector('#kbt-f-tpl').value;
    if (!esn || !wo) { alert('ESN und WO sind Pflichtfelder.'); return; }
    await kbTardisConfirmCreate(esn, wo, tpl, itemEl);
  });
}

async function kbTardisConfirmCreate(esn, wo, tplName, itemEl) {
  // Create instance + add card to board
  await pfCreateInstanceData(esn, wo, tplName, {});
  // Update item to "in board" state
  itemEl.classList.add('in-board');
  itemEl.querySelector('.kbt-item-dot').textContent = '●';
  itemEl.querySelector('.kbt-item-body .kbt-create-form')?.remove();
  const addBtn = itemEl.querySelector('.kbt-add-btn');
  if (addBtn) addBtn.remove();
  const badge = document.createElement('div');
  badge.className = 'kbt-in-board-badge';
  badge.textContent = '● Im Board';
  itemEl.querySelector('.kbt-item-body').appendChild(badge);
  // Add card to Planned column
  const inst = _pfInstances.find(i=>String(i.esn)===esn&&String(i.wo)===wo);
  if (inst) {
    const card = createKanbanCard({...inst, kb_status:'planned'});
    document.getElementById('kb-cards-planned').appendChild(card);
    kbUpdateCounts();
  }
}
</script>

<!-- ══ Process Flow CSS ═══════════════════════════════════════════ -->
<style>
  .pf-tpl-wrap {
    height: 0; flex-shrink: 0; overflow: hidden;
    background: #efe9de; box-sizing: border-box;
    border-top: 2px solid rgba(232,70,70,0.35);
    box-shadow: 0 -6px 40px rgba(0,0,0,0.12);
    display: flex; flex-direction: column;
    transition: height 300ms cubic-bezier(0.4,0,0.2,1);
  }
  .pf-tpl-wrap.open { height: 85vh; }
  .pf-tpl-drag-handle {
    width: 40px; height: 4px; background: rgba(232,70,70,0.25);
    border-radius: 2px; margin: 10px auto 0; flex-shrink: 0; cursor: pointer;
    transition: background 120ms;
  }
  .pf-tpl-drag-handle:hover { background: rgba(232,70,70,0.5); }
  .pf-tpl-header-row {
    display: flex; align-items: flex-end; justify-content: space-between;
    padding: 0 32px; flex-shrink: 0;
  }
  .pf-tpl-heading {
    font-size: 26px; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase;
    color: rgba(140,30,30,0.55); padding: 6px 0 0; user-select: none; line-height: 1.2;
  }
  .pf-tpl-close-btn {
    background: none; border: none; color: rgba(180,40,40,0.40);
    font-size: 26px; line-height: 1; cursor: pointer; padding: 0 0 4px;
    transition: color 120ms; font-family: inherit;
  }
  .pf-tpl-close-btn:hover { color: rgba(180,40,40,0.85); }
  .pf-canvas { flex:1; overflow-y: auto; }
  .pf-tpl-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:10px 32px 12px; border-bottom:1px solid rgba(232,70,70,0.10); }
  .pf-tpl-tabs { display:flex; gap:6px; flex-wrap:wrap; align-items:center; flex:1; }
  .pf-tpl-tab {
    background:none; border:1px solid rgba(232,70,70,0.22);
    color:rgba(140,30,30,0.60); padding:5px 16px; font-size:12px;
    font-family:inherit; border-radius:20px; cursor:pointer; font-weight:500;
    transition:background 120ms, border-color 120ms, color 120ms;
    letter-spacing:0.04em;
  }
  .pf-tpl-tab:hover { background:rgba(232,70,70,0.07); color:rgba(120,20,20,0.85); }
  .pf-tpl-tab.active {
    background:rgba(232,70,70,0.13); border-color:rgba(232,70,70,0.55);
    color:rgba(120,20,20,0.92); font-weight:700;
  }
  .pf-tpl-tab-rename {
    outline:none; min-width:60px; width:auto; text-align:center;
    cursor:text; caret-color:rgba(140,30,30,0.80);
  }
  .pf-tpl-tab-rename:focus {
    border-color:rgba(232,70,70,0.75); background:rgba(255,255,255,0.55);
  }
  /* ── Top bar ── */
  .pf-topbar {
    display:flex; align-items:center; justify-content:space-between;
    padding:10px 24px; gap:16px;
    border-bottom:1px solid rgba(232,70,70,0.18);
    background:rgba(255,255,255,0.22);
  }
  .pf-toolbar { display:flex; align-items:center; gap:10px; flex-shrink:0; }
  .pf-engine-label { font-size:10px; color:rgba(180,40,40,0.50); letter-spacing:0.08em; text-transform:uppercase; }
  .pf-edit-btn {
    background:none; border:1px solid rgba(232,70,70,0.25); color:rgba(180,40,40,0.60);
    padding:4px 10px; font-size:11px; cursor:pointer; font-family:inherit;
    border-radius:3px; transition:background 120ms;
  }
  .pf-edit-btn:hover { background:rgba(232,70,70,0.08); }
  .pf-edit-btn.active { background:rgba(232,70,70,0.12); border-color:rgba(232,70,70,0.50); color:rgba(140,30,30,0.85); }

  .pf-input {
    background:rgba(255,255,255,0.65); border:1px solid rgba(232,70,70,0.25);
    color:rgba(120,20,20,0.85); padding:5px 10px; font-size:12px;
    font-family:inherit; outline:none; border-radius:3px; width:160px;
  }
  .pf-input:focus { border-color:rgba(232,70,70,0.55); }
  .pf-select {
    background:rgba(255,255,255,0.65); border:1px solid rgba(232,70,70,0.25);
    color:rgba(120,20,20,0.85); padding:5px 10px; font-size:12px;
    font-family:inherit; outline:none; border-radius:3px; cursor:pointer;
  }
  .pf-btn-primary {
    background:rgba(232,70,70,0.12); border:1px solid rgba(232,70,70,0.35);
    color:rgba(140,30,30,0.85); padding:5px 14px; font-size:12px;
    cursor:pointer; font-family:inherit; border-radius:3px;
  }
  .pf-btn-primary:hover { background:rgba(232,70,70,0.22); }
  .pf-btn-ghost {
    background:none; border:1px solid rgba(232,70,70,0.18); color:rgba(180,40,40,0.55);
    padding:5px 12px; font-size:12px; cursor:pointer; font-family:inherit; border-radius:3px;
  }

  /* ── Canvas ── */
  .pf-canvas { width:100%; box-sizing:border-box; display:flex; flex-direction:column; position:relative; }
  .pf-empty { padding:48px; text-align:center; font-size:12px; color:rgba(180,40,40,0.35); letter-spacing:0.04em; }

  /* ── Lane ── */
  .pf-lane { display:flex; flex-direction:row; border-bottom:1px solid rgba(232,70,70,0.12); min-height:110px; }
  .pf-lane:last-child { border-bottom:none; }
  .pf-lane-header {
    width:88px; flex-shrink:0; border-right:1px solid rgba(232,70,70,0.12);
    padding:14px 8px; display:flex; flex-direction:column; align-items:center;
    justify-content:flex-start; gap:5px; background:rgba(232,70,70,0.025);
  }
  .pf-lane-icon  { font-size:14px; color:rgba(180,40,40,0.50); }
  .pf-lane-label { font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:0.12em; color:rgba(180,40,40,0.55); text-align:center; }
  .pf-lane-body  { flex:1; overflow-x:auto; display:flex; flex-direction:row; align-items:flex-start; gap:10px; padding:12px 16px; }

  /* ── Procedure group ── */
  .pf-procedure {
    flex-shrink:0; border:1px solid rgba(232,70,70,0.16); border-radius:4px;
    background:rgba(255,255,255,0.16); padding:8px 10px; display:flex; flex-direction:column; gap:8px;
  }
  .pf-procedure-header {
    font-size:8px; font-weight:700; text-transform:uppercase; letter-spacing:0.14em;
    color:rgba(180,40,40,0.42); padding-bottom:5px; border-bottom:1px solid rgba(232,70,70,0.10);
    display:flex; align-items:center; justify-content:space-between; gap:8px;
    cursor:pointer; user-select:none; white-space:nowrap;
  }
  .pf-procedure-toggle { font-size:8px; color:rgba(180,40,40,0.30); }
  .pf-steps { display:flex; flex-direction:row; gap:6px; flex-wrap:nowrap; }

  /* ── Step card (compact) ── */
  .pf-step {
    width:96px; min-height:58px; padding:7px 8px 7px 10px;
    background:rgba(255,255,255,0.45); border:1px solid rgba(232,70,70,0.18);
    border-left-width:3px; border-radius:3px; cursor:pointer; position:relative;
    transition:background 120ms, width 180ms; display:flex; flex-direction:column; gap:3px;
    box-sizing:border-box; flex-shrink:0;
  }
  .pf-step:hover { background:rgba(255,255,255,0.72); }
  .pf-step.open  { width:200px; z-index:20; box-shadow:0 2px 12px rgba(140,30,30,0.12); }
  .pf-step.status-pending { border-left-color:rgba(232,70,70,0.20); }
  .pf-step.status-running { border-left-color:#f59e0b; }
  .pf-step.status-done    { border-left-color:#22c55e; background:rgba(34,197,94,0.07); }
  .pf-step.status-error   { border-left-color:#e84646; background:rgba(232,70,70,0.07); }
  .pf-step.status-skipped { border-left-color:rgba(180,40,40,0.15); opacity:0.50; }
  .pf-step.extra-step     { border-style:dashed; }
  .pf-step-status-icon { font-size:9px; line-height:1; flex-shrink:0; }
  .pf-step-top { display:flex; align-items:flex-start; gap:4px; }
  .pf-step-label { font-size:10px; font-weight:600; color:rgba(120,20,20,0.85); line-height:1.35; }
  .pf-step-type  { font-size:9px; color:rgba(180,40,40,0.45); margin-left:auto; flex-shrink:0; }

  /* Expanded panel */
  .pf-step-expanded {
    display:none; margin-top:6px; padding-top:6px;
    border-top:1px solid rgba(232,70,70,0.12);
    flex-direction:column; gap:5px;
  }
  .pf-step.open .pf-step-expanded { display:flex; }
  .pf-step-flow-link { font-size:9px; color:rgba(180,40,40,0.50); }
  .pf-step-done-at   { font-size:8px; color:rgba(180,40,40,0.38); }
  .pf-step-actions   { display:flex; gap:4px; flex-wrap:wrap; margin-top:2px; }
  .pf-act-btn {
    padding:3px 7px; font-size:9px; font-family:inherit; border-radius:2px;
    cursor:pointer; border:1px solid rgba(232,70,70,0.22); background:none;
    color:rgba(140,30,30,0.72); transition:background 120ms; white-space:nowrap;
  }
  .pf-act-btn:hover  { background:rgba(232,70,70,0.10); }
  .pf-act-btn.start  { border-color:#f59e0b; color:#92400e; }
  .pf-act-btn.done   { border-color:#22c55e; color:#166534; }
  .pf-act-btn.err    { border-color:#e84646; color:#991b1b; }
  /* ── Edit mode controls ── */
  .pf-edit-micro {
    padding:2px 5px; font-size:9px; cursor:pointer; border-radius:2px;
    border:1px solid rgba(232,70,70,0.22); background:none; color:rgba(140,30,30,0.72);
    font-family:inherit; transition:background 120ms; line-height:1.4;
  }
  .pf-edit-micro:hover { background:rgba(232,70,70,0.10); }
  .pf-edit-micro.del   { border-color:#e84646; color:#991b1b; }
  .pf-edit-micro.del:hover { background:rgba(232,70,70,0.15); }
  .pf-lane-input {
    font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:0.12em;
    color:rgba(180,40,40,0.72); background:rgba(232,70,70,0.06);
    border:1px solid rgba(232,70,70,0.28); border-radius:2px; padding:2px 4px; width:100%;
    font-family:inherit; box-sizing:border-box;
  }
  .pf-step-edit-input {
    font-size:9px; font-family:inherit; width:100%; padding:2px 4px; box-sizing:border-box;
    background:rgba(255,255,255,0.85); border:1px solid rgba(232,70,70,0.28);
    border-radius:2px; color:rgba(80,10,10,0.85); margin-bottom:3px;
  }
  .pf-step-edit-sel {
    font-size:9px; font-family:inherit; padding:2px 3px; width:100%; box-sizing:border-box;
    background:rgba(255,255,255,0.85); border:1px solid rgba(232,70,70,0.28);
    border-radius:2px; color:rgba(80,10,10,0.85); margin-bottom:3px;
  }
  .pf-add-lane-row { padding:8px 16px; }
  .pf-paths-section { padding:8px 16px 4px; border-bottom:1px solid rgba(232,70,70,0.12); margin-bottom:4px; display:flex; flex-direction:column; gap:5px; }
  .pf-path-row { display:flex; align-items:center; gap:10px; }
  .pf-path-label { font-size:10px; font-weight:700; letter-spacing:0.06em; text-transform:uppercase;
    color:rgba(180,40,40,0.45); width:72px; flex-shrink:0; }
  .pf-path-input { flex:1; background:rgba(232,70,70,0.05); border:1px solid rgba(232,70,70,0.20);
    color:rgba(120,20,20,0.85); font-size:11px; font-family:monospace; padding:3px 6px;
    border-radius:3px; outline:none; min-width:0; }
  .pf-path-input:focus { border-color:rgba(232,70,70,0.50); background:rgba(232,70,70,0.08); }
  .pf-path-value { font-size:11px; font-family:monospace; color:rgba(120,20,20,0.70); }
  .pf-color-input { width:28px; height:22px; padding:0; border:1px solid rgba(232,70,70,0.25); border-radius:3px; cursor:pointer; background:none; overflow:hidden; }
  .pf-color-swatch { display:inline-block; width:14px; height:14px; border-radius:3px; border:1px solid rgba(0,0,0,0.18); flex-shrink:0; }
  .pf-email-section { padding:6px 16px 8px; border-bottom:1px solid rgba(232,70,70,0.12); display:flex; flex-direction:column; gap:4px; }
  .pf-email-heading { font-size:10px; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:rgba(180,40,40,0.40); padding-bottom:2px; }
  .pf-email-row { display:flex; align-items:center; gap:6px; }
  .pf-email-input { background:rgba(232,70,70,0.05); border:1px solid rgba(232,70,70,0.20);
    color:rgba(120,20,20,0.85); font-size:11px; padding:2px 5px; border-radius:3px; outline:none; min-width:0; }
  .pf-email-input:focus { border-color:rgba(232,70,70,0.50); }
  .pf-email-input.key  { width:70px; flex-shrink:0; font-family:monospace; }
  .pf-email-input.lbl  { flex:1; }
  .pf-email-input.file { flex:2; font-family:monospace; }
  .pf-email-del { background:none; border:none; cursor:pointer; color:rgba(180,40,40,0.35);
    font-size:12px; padding:0 2px; transition:color 120ms; flex-shrink:0; }
  .pf-email-del:hover { color:rgba(180,40,40,0.80); }
  .pf-email-add { background:none; border:1px dashed rgba(232,70,70,0.25); color:rgba(180,40,40,0.45);
    font-size:10px; padding:2px 8px; border-radius:3px; cursor:pointer; margin-top:2px; transition:all 120ms; }
  .pf-email-add:hover { border-color:rgba(232,70,70,0.50); color:rgba(180,40,40,0.80); }
  .pf-folder-section { border-color: rgba(59,130,246,0.15); }
  .pf-folder-row { display:flex; align-items:center; gap:6px; }
  .pf-folder-dot-preview { width:10px; height:10px; border-radius:50%; flex-shrink:0;
    background:rgba(59,130,246,0.18); border:1.5px solid rgba(59,130,246,0.45); }
  .pf-folder-path { font-family:monospace; }
  .pf-add-lane-btn, .pf-add-proc-btn {
    padding:3px 8px; font-size:9px; font-family:inherit; border-radius:2px; cursor:pointer;
    border:1px dashed rgba(232,70,70,0.35); background:none; color:rgba(140,30,30,0.65);
    transition:background 120ms;
  }
  .pf-add-lane-btn:hover, .pf-add-proc-btn:hover { background:rgba(232,70,70,0.08); }
  /* ── Flow Trigger Modal ── */
  .pftm-backdrop {
    position:fixed; inset:0; background:rgba(30,10,10,0.45); z-index:900;
  }
  .pftm {
    position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);
    z-index:901; width:clamp(340px,40vw,620px); max-height:80vh;
    background:#fdf8f2; border:1px solid rgba(232,70,70,0.25); border-radius:4px;
    box-shadow:0 8px 32px rgba(80,10,10,0.18); display:flex; flex-direction:column;
  }
  .pftm-header {
    display:flex; align-items:center; justify-content:space-between;
    padding:12px 16px; border-bottom:1px solid rgba(232,70,70,0.15); flex-shrink:0;
  }
  .pftm-title { font-size:11px; font-weight:700; color:rgba(100,20,20,0.85); letter-spacing:0.04em; }
  .pftm-close {
    background:none; border:none; font-size:16px; cursor:pointer; color:rgba(180,40,40,0.55);
    line-height:1; padding:2px 5px; border-radius:2px; font-family:inherit;
  }
  .pftm-close:hover { background:rgba(232,70,70,0.10); color:#e84646; }
  .pftm-body { padding:14px 16px; overflow-y:auto; flex:1; }
  .pftm-loading { display:flex; align-items:center; gap:10px; color:rgba(100,20,20,0.65); font-size:10px; }
  .pftm-spinner {
    width:14px; height:14px; border:2px solid rgba(232,70,70,0.20);
    border-top-color:#e84646; border-radius:50%; animation:pftm-spin 0.7s linear infinite; flex-shrink:0;
  }
  @keyframes pftm-spin { to { transform:rotate(360deg); } }
  .pftm-node-list { display:flex; flex-direction:column; gap:6px; }
  .pftm-node {
    display:flex; flex-direction:column; gap:3px; padding:8px 10px;
    border:1px solid rgba(232,70,70,0.15); border-radius:3px; background:rgba(255,255,255,0.55);
  }
  .pftm-node.ok  { border-left:3px solid #22c55e; }
  .pftm-node.err { border-left:3px solid #e84646; }
  .pftm-node-top { display:flex; align-items:center; gap:6px; }
  .pftm-node-icon { font-size:9px; flex-shrink:0; }
  .pftm-node-label { font-size:10px; font-weight:600; color:rgba(80,10,10,0.80); flex:1; }
  .pftm-node-status { font-size:9px; color:rgba(140,30,30,0.55); }
  .pftm-node-out { font-size:9px; color:rgba(100,20,20,0.55); padding-left:16px; line-height:1.5; }
  .pftm-node-err-msg { font-size:9px; color:#c0392b; padding-left:16px; white-space:pre-wrap; }
  .pftm-dl-btn {
    display:inline-block; margin-top:4px; padding:3px 9px; font-size:9px; font-family:inherit;
    border:1px solid rgba(34,197,94,0.40); border-radius:2px; color:#166534;
    text-decoration:none; background:rgba(34,197,94,0.08); cursor:pointer;
  }
  .pftm-dl-btn:hover { background:rgba(34,197,94,0.16); }
  .pftm-err-banner {
    padding:8px 10px; background:rgba(232,70,70,0.08); border:1px solid rgba(232,70,70,0.25);
    border-radius:3px; font-size:10px; color:#c0392b;
  }
  .pftm-footer { display:flex; justify-content:flex-end; padding:10px 16px; border-top:1px solid rgba(232,70,70,0.12); flex-shrink:0; }
  .pftm-btn {
    padding:4px 14px; font-size:10px; font-family:inherit; border-radius:2px; cursor:pointer;
    border:1px solid rgba(232,70,70,0.25); background:none; color:rgba(140,30,30,0.75);
  }
  .pftm-btn:hover { background:rgba(232,70,70,0.08); }
  /* ── Pre-flight phases ── */
  .pftm-section-label { font-size:9px; font-weight:700; color:rgba(80,10,10,0.50); letter-spacing:0.08em; text-transform:uppercase; margin-bottom:4px; }
  .pftm-ctx-row { display:flex; align-items:center; gap:6px; padding:4px 8px; border-radius:2px; background:rgba(34,197,94,0.07); border:1px solid rgba(34,197,94,0.20); margin-bottom:3px; font-size:9px; }
  .pftm-ctx-ok { color:#16a34a; font-size:9px; flex-shrink:0; }
  .pftm-ctx-label { color:rgba(80,10,10,0.65); font-weight:600; min-width:90px; }
  .pftm-ctx-val { color:rgba(80,10,10,0.80); font-family:monospace; font-size:9px; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .pftm-ctx-row.pending { background:rgba(234,179,8,0.06); border-color:rgba(234,179,8,0.25); }
  .pftm-ctx-row.pending .pftm-ctx-ok { color:#b45309; }
  .pftm-ctx-detail { font-size:8px; color:rgba(80,10,10,0.40); font-family:monospace; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
  .pftm-blocker-row { display:flex; align-items:flex-start; gap:6px; padding:5px 8px; border-radius:2px; background:rgba(234,179,8,0.07); border:1px solid rgba(234,179,8,0.30); margin-bottom:3px; font-size:9px; color:#92400e; }
  .pftm-preflight-actions { display:flex; gap:6px; margin-top:10px; flex-wrap:wrap; }
  .pftm-btn-primary { background:rgba(232,70,70,0.12); border-color:rgba(232,70,70,0.35); color:#991b1b; font-weight:600; }
  .pftm-btn-primary:hover { background:rgba(232,70,70,0.22); }
  /* ── Check output in modal ── */
  .pftm-check-pills { display:flex; flex-wrap:wrap; gap:4px; padding:4px 0 2px; }
  .pftm-pill { display:inline-flex; align-items:center; padding:2px 7px; border-radius:2px; font-size:9px; font-weight:600; cursor:default; }
  .pftm-pill.ok  { background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.30); color:#166534; }
  .pftm-pill.err { background:rgba(232,70,70,0.10); border:1px solid rgba(232,70,70,0.25); color:#991b1b; }
  .pftm-overall-ok  { font-size:10px; font-weight:700; color:#16a34a; margin-bottom:4px; }
  .pftm-overall-err { font-size:10px; font-weight:700; color:#c0392b; margin-bottom:4px; }
  .pftm-allscans { margin-top:8px; display:flex; flex-direction:column; gap:3px; }
  .pftm-allscans-row { display:flex; align-items:center; gap:4px; flex-wrap:wrap; }
  .pftm-allscans-code { font-size:8.5px; font-weight:700; color:rgba(30,30,60,0.45); width:72px; flex-shrink:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .pftm-scan-chip { font-size:8.5px; padding:1px 5px; border-radius:2px; border:1px solid; cursor:default; }
  .pftm-scan-chip.ok       { background:rgba(34,197,94,0.10); border-color:rgba(34,197,94,0.30); color:#166534; }
  .pftm-scan-chip.err      { background:rgba(232,70,70,0.08); border-color:rgba(232,70,70,0.22); color:#991b1b; }
  .pftm-scan-chip.selected { font-weight:700; box-shadow:0 0 0 1.5px currentColor; }
  /* ── Table output in modal ── */
  .pftm-tbl-wrap { overflow:auto; max-height:180px; border:1px solid rgba(232,70,70,0.12); border-radius:2px; margin-top:4px; }
  .pftm-tbl { border-collapse:collapse; font-size:9px; width:100%; }
  .pftm-tbl th { background:rgba(232,70,70,0.06); padding:3px 6px; text-align:left; font-weight:600; color:rgba(80,10,10,0.65); border-bottom:1px solid rgba(232,70,70,0.12); white-space:nowrap; position:sticky; top:0; }
  .pftm-tbl td { padding:3px 6px; color:rgba(80,10,10,0.80); border-bottom:1px solid rgba(232,70,70,0.06); white-space:nowrap; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
  .pftm-tbl tr:last-child td { border-bottom:none; }
  .pftm-tbl-info { font-size:9px; color:rgba(100,20,20,0.45); margin-top:3px; }
  /* ── Last-run badge on flow trigger steps ── */
  .pfa-step-last-run { font-size:8px; color:rgba(100,20,20,0.40); padding-top:1px; }
  .pfa-step-last-run.ok  { color:rgba(22,101,52,0.65); }
  .pfa-step-last-run.err { color:rgba(192,57,43,0.65); }
  /* ── Cert Build inline config in pre-flight modal ── */
  .pftm-cert-section { margin-top:10px; border:1px solid rgba(34,197,94,0.22); border-radius:5px; padding:8px 10px; background:rgba(34,197,94,0.04); }
  .pftm-cert-title { font-size:9px; font-weight:700; letter-spacing:0.12em; text-transform:uppercase; color:#166534; margin-bottom:8px; }
  .pftm-cert-sublabel { font-size:9px; color:rgba(80,10,10,0.45); letter-spacing:0.10em; text-transform:uppercase; margin-bottom:5px; margin-top:8px; }
  .pftm-cert-chips { display:flex; flex-wrap:wrap; gap:4px; }
  .pftm-cert-chip { font-size:9px; padding:2px 8px; border-radius:10px; border:1px solid rgba(80,10,10,0.20); background:rgba(80,10,10,0.03); color:rgba(80,10,10,0.55); cursor:pointer; }
  .pftm-cert-chip.active { border-color:rgba(34,197,94,0.70); background:rgba(34,197,94,0.12); color:#166534; font-weight:600; }
  .pftm-cert-scan-btn { font-size:9px; padding:3px 10px; border-radius:3px; border:1px solid rgba(232,70,70,0.35); background:rgba(232,70,70,0.08); color:rgba(232,70,70,0.80); cursor:pointer; margin-bottom:6px; margin-top:4px; }
  .pftm-cert-scan-btn:disabled { opacity:0.4; cursor:default; }
  /* ── TARDIS Browser Modal ── */
  .kbt-backdrop { position:fixed; inset:0; background:rgba(30,10,10,0.45); z-index:900; }
  .kbt-modal {
    position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);
    z-index:901; width:clamp(380px,52vw,700px); max-height:82vh;
    background:#fdf6f6; border:1px solid rgba(232,70,70,0.20); border-radius:4px;
    box-shadow:0 8px 32px rgba(80,10,10,0.18); display:flex; flex-direction:column;
  }
  .kbt-header {
    display:flex; align-items:center; justify-content:space-between;
    padding:10px 14px; border-bottom:1px solid rgba(232,70,70,0.15); flex-shrink:0;
  }
  .kbt-title { font-size:11px; font-weight:700; color:rgba(100,20,20,0.85); letter-spacing:0.04em; }
  .kbt-close { background:none; border:none; font-size:16px; cursor:pointer; color:rgba(180,40,40,0.55); padding:2px 5px; border-radius:2px; font-family:inherit; line-height:1; }
  .kbt-close:hover { background:rgba(232,70,70,0.10); color:#e84646; }
  .kbt-filters { display:flex; gap:6px; padding:8px 14px; flex-shrink:0; border-bottom:1px solid rgba(232,70,70,0.08); }
  .kbt-sel {
    flex:1; padding:3px 6px; font-size:10px; font-family:inherit;
    border:1px solid rgba(232,70,70,0.22); border-radius:2px;
    background:#fff8f8; color:rgba(80,10,10,0.85); outline:none;
  }
  .kbt-load-btn {
    padding:3px 12px; font-size:10px; font-family:inherit; border-radius:2px; cursor:pointer;
    border:1px solid rgba(232,70,70,0.30); background:rgba(232,70,70,0.09);
    color:rgba(140,30,30,0.85); font-weight:600; white-space:nowrap; flex-shrink:0;
  }
  .kbt-load-btn:hover { background:rgba(232,70,70,0.18); }
  .kbt-load-btn:disabled { opacity:.45; cursor:default; }
  .kbt-search-row { display:flex; align-items:center; gap:8px; padding:6px 14px; flex-shrink:0; }
  .kbt-search {
    flex:1; padding:4px 8px; font-size:10px; font-family:inherit;
    border:1px solid rgba(232,70,70,0.20); border-radius:2px;
    background:#fff8f8; color:rgba(80,10,10,0.85); outline:none;
  }
  .kbt-search:focus { border-color:rgba(232,70,70,0.45); }
  .kbt-count { font-size:9px; color:rgba(140,30,30,0.50); white-space:nowrap; }
  .kbt-list { flex:1; overflow-y:auto; padding:4px 0; }
  .kbt-item {
    display:flex; align-items:flex-start; gap:8px; padding:6px 14px;
    border-bottom:1px solid rgba(232,70,70,0.06); transition:background 100ms;
  }
  .kbt-item:hover { background:rgba(232,70,70,0.04); }
  .kbt-item.in-board { opacity:.55; }
  .kbt-item-dot { font-size:9px; margin-top:2px; flex-shrink:0; color:rgba(140,30,30,0.35); }
  .kbt-item.in-board .kbt-item-dot { color:#22c55e; }
  .kbt-item-body { flex:1; min-width:0; }
  .kbt-item-name { font-size:9px; color:rgba(80,10,10,0.80); word-break:break-all; line-height:1.4; }
  .kbt-item-meta { font-size:8px; color:rgba(140,30,30,0.50); margin-top:2px; }
  .kbt-in-board-badge { font-size:8px; color:#16a34a; font-weight:600; margin-top:2px; }
  .kbt-add-btn {
    padding:2px 9px; font-size:9px; font-family:inherit; border-radius:2px; cursor:pointer;
    border:1px solid rgba(232,70,70,0.25); background:none; color:rgba(140,30,30,0.70);
    white-space:nowrap; flex-shrink:0; align-self:flex-start; margin-top:1px;
  }
  .kbt-add-btn:hover { background:rgba(232,70,70,0.10); }
  .kbt-create-form {
    margin-top:6px; padding:6px 8px; background:rgba(232,70,70,0.05);
    border:1px solid rgba(232,70,70,0.14); border-radius:2px;
    display:flex; flex-wrap:wrap; gap:5px; align-items:center;
  }
  .kbt-inp {
    padding:2px 6px; font-size:9px; font-family:inherit; border-radius:2px;
    border:1px solid rgba(232,70,70,0.22); background:#fff8f8;
    color:rgba(80,10,10,0.85); outline:none; width:80px;
  }
  .kbt-inp:focus { border-color:rgba(232,70,70,0.45); }
  .kbt-inp.wide { width:130px; }
  .kbt-form-label { font-size:8px; color:rgba(140,30,30,0.55); }
  .kbt-confirm-btn {
    padding:2px 9px; font-size:9px; font-family:inherit; border-radius:2px; cursor:pointer;
    border:1px solid rgba(34,197,94,0.35); background:rgba(34,197,94,0.10);
    color:#166534; font-weight:600;
  }
  .kbt-confirm-btn:hover { background:rgba(34,197,94,0.20); }
  .kbt-cancel-btn {
    padding:2px 7px; font-size:9px; font-family:inherit; border-radius:2px; cursor:pointer;
    border:1px solid rgba(232,70,70,0.20); background:none; color:rgba(140,30,30,0.60);
  }
  .kbt-footer { padding:6px 14px; border-top:1px solid rgba(232,70,70,0.08); flex-shrink:0; }
  .kbt-more-btn {
    padding:3px 12px; font-size:9px; font-family:inherit; border-radius:2px; cursor:pointer;
    border:1px solid rgba(232,70,70,0.22); background:none; color:rgba(140,30,30,0.65); width:100%;
  }
  .kbt-more-btn:hover { background:rgba(232,70,70,0.07); }
  .kbt-empty { padding:20px 14px; text-align:center; font-size:10px; color:rgba(140,30,30,0.45); }
  .kbt-tpl-sel {
    padding:2px 5px; font-size:9px; font-family:inherit; border-radius:2px;
    border:1px solid rgba(232,70,70,0.22); background:#fff8f8;
    color:rgba(80,10,10,0.85); outline:none;
  }
</style>

<!-- ══ Process Flow JS ════════════════════════════════════════════ -->
<script>
let _pfTemplates        = [];
let _pfInstances        = [];
let _pfActiveTab        = null;
let _pfInstanceData     = {};
let _pfEditMode         = false;   // edits shared template
let _pfInstanceEditMode = false;   // edits this instance's snapshot
let _pfFlowList         = null;    // null = not yet loaded
let _pfOpenPhases       = new Set();
let _pfPhasesInitialized = false;

function pfAnyEdit() { return _pfEditMode || _pfInstanceEditMode; }

const PF_STATUS_ICON = { pending:'◻', running:'⟳', done:'✓', error:'✗', skipped:'○' };

async function pfInit() {
  const [tpls, insts, flows] = await Promise.all([
    fetch('/api/process/templates').then(r=>r.json()).catch(()=>[]),
    fetch('/api/process/instances').then(r=>r.json()).catch(()=>[]),
    fetch('/api/flow/list').then(r=>r.json()).catch(()=>[]),
  ]);
  _pfTemplates = tpls;
  _pfInstances = insts;
  _pfFlowList  = flows;
  pfTplPopulateSel();
  kbInitSapCards();
  kbRenderBoard(insts);
}

// ── SAP Shop-Spalte aus eingebetteten Daten befüllen ──────────────
function kbInitSapCards() {
  try {
    const raw = document.getElementById('sap-data-script')?.textContent || '[]';
    const sapData = JSON.parse(raw);
    const shopCol = document.getElementById('kb-cards-shop');
    if (!shopCol) return;
    shopCol.innerHTML = '';
    sapData.forEach(engine => {
      const card = createKanbanCard({
        kb_engine_type: engine.engineType,
        sap_engine:     engine.engineType,
        esn:            engine.esn,
        wo:             engine.workorder,
        kb_customer:    engine.customer,
        kb_status:      'shop',
        kb_dates: { b3: engine.date_b3, ts: engine.date_ts, te: engine.date_te },
      });
      shopCol.appendChild(card);
    });
    kbUpdateCounts();
  } catch(e) { console.warn('[SAP] Fehler beim Laden der Shop-Daten:', e); }
}

// ── Template editor drawer (slides up from bottom) ───────────────
function pfToggleTplEditor() {
  const wrap = document.getElementById('pf-tpl-wrap');
  const btn  = document.querySelector('.kb-tpl-icon-btn[onclick*="pfToggleTplEditor"]');
  const open = !wrap.classList.contains('open');
  wrap.classList.toggle('open', open);
  btn?.classList.toggle('active', open);
  if (open) pfRenderTemplateCanvas();
}

function pfTplPopulateSel(activeName) {
  const sel = document.getElementById('pf-tpl-sel'); if (!sel) return;
  const cur = activeName || document.querySelector('#pf-tpl-sel .pf-tpl-tab.active')?.dataset.tpl || _pfTemplates[0]?.engine_type || '';
  sel.innerHTML = _pfTemplates.map(t => {
    if (_pfEditMode && t.engine_type === cur)
      return `<input class="pf-tpl-tab active pf-tpl-tab-rename" data-tpl="${t.engine_type}" value="${t.engine_type}" title="Template umbenennen" onblur="pfRenameTemplate(this.dataset.tpl,this.value)" onkeydown="if(event.key==='Enter')this.blur()" onclick="event.stopPropagation()">`;
    return `<button class="pf-tpl-tab${t.engine_type===cur?' active':''}" data-tpl="${t.engine_type}" onclick="pfTplTabClick(this)">${t.engine_type}</button>`;
  }).join('');
}
function pfTplTabClick(btn) {
  document.querySelectorAll('#pf-tpl-sel .pf-tpl-tab').forEach(b=>b.classList.remove('active'));
  btn.classList.add('active');
  pfTplPopulateSel(btn.dataset.tpl);
  pfRenderTemplateCanvas();
}

function pfRenameTemplate(oldName, newName) {
  newName = (newName || '').trim();
  if (!newName || newName === oldName) { pfTplPopulateSel(oldName); return; }
  if (_pfTemplates.find(t => t.engine_type === newName)) {
    alert('Template "' + newName + '" existiert bereits.');
    pfTplPopulateSel(oldName);
    return;
  }
  const tpl = _pfTemplates.find(t => t.engine_type === oldName);
  if (!tpl) return;
  tpl.engine_type = newName;
  pfTplPopulateSel(newName);
  pfSaveTemplate();
}

function pfTplSelChange() { pfRenderTemplateCanvas(); }

function pfNewTemplate() {
  const name = prompt('Engine-Typ Name (z.B. CFM56-5B):');
  if (!name || !name.trim()) return;
  const n = name.trim();
  if (_pfTemplates.find(t=>t.engine_type===n)) { alert('Template "'+n+'" existiert bereits.'); return; }
  const tpl = {engine_type:n, lanes:[], connections:[], milestones:[]};
  _pfTemplates.push(tpl);
  pfTplPopulateSel(n);
  _pfEditMode = true;
  document.getElementById('pf-edit-tpl-btn')?.classList.add('active');
  pfSaveTemplate();
  pfRenderTemplateCanvas();
}

function pfGetSelectedTpl() {
  const name = document.querySelector('#pf-tpl-sel .pf-tpl-tab.active')?.dataset.tpl || _pfTemplates[0]?.engine_type;
  return _pfTemplates.find(t=>t.engine_type===name) || null;
}

function pfRenderTemplateCanvas() {
  const canvas = document.getElementById('pf-tpl-canvas'); if (!canvas) return;
  const tpl = pfGetSelectedTpl();
  if (!tpl) { canvas.innerHTML='<div class="pf-empty">Kein Template ausgewählt.</div>'; return; }
  const prevInstMode = _pfInstanceEditMode;
  _pfInstanceEditMode = false;

  // ── TARDIS-Projekt Sektion ───────────────────────────────────────
  const tardisProj = tpl.tardis_project || '';
  const tardisSection = _pfEditMode
    ? `<div class="pf-paths-section" style="margin-bottom:8px;border-left:2px solid rgba(99,102,241,0.35);padding-left:8px">
        <div class="pf-path-row">
          <span class="pf-path-label" style="color:rgba(99,102,241,0.80)">TARDIS Projekt</span>
          <input class="pf-path-input" value="${tardisProj}" placeholder="z.B. CFM56.CFM56-7B"
            onchange="pfUpdateTardisProject(this.value)" onclick="event.stopPropagation()"
            list="pf-tardis-proj-list" id="pf-tardis-proj-input">
          <datalist id="pf-tardis-proj-list"></datalist>
        </div>
      </div>`
    : `<div class="pf-paths-section" style="margin-bottom:8px;border-left:2px solid rgba(99,102,241,0.35);padding-left:8px">
        <div class="pf-path-row">
          <span class="pf-path-label" style="color:rgba(99,102,241,0.80)">TARDIS Projekt</span>
          <span class="pf-path-value">${tardisProj || '<span style="opacity:0.35">—</span>'}</span>
        </div>
      </div>`;
  if (_pfEditMode) {
    fetch('/api/tardis/projects').then(r=>r.json()).then(projs => {
      const dl = document.getElementById('pf-tardis-proj-list');
      if (dl) dl.innerHTML = (projs||[]).map(p=>`<option value="${p.name||p}">`).join('');
    }).catch(()=>{});
  }

  const paths = tpl.paths || {};
  const pathFields = [
    { key: 'base_root',    label: 'Basispfad',  placeholder: 'L:\\lgroup_CFM\\Auftr_CFM56-7B' },
    { key: 'wrb_folder',   label: 'WRB-Ordner', placeholder: 'L:\\lgroup\\WRB_Revision (leer = Default)' },
    { key: 'archive_root', label: 'Testarchiv', placeholder: 'L:\\lgroup\\Pruefstd\\10_Testarchiv\\Stand4_Testunterlagen\\CFM56-7B' },
  ];
  const pathRows = pathFields.map(f => {
    const val = paths[f.key] || '';
    if (_pfEditMode) {
      return `<div class="pf-path-row">
        <span class="pf-path-label">${f.label}</span>
        <input class="pf-path-input" value="${val}" placeholder="${f.placeholder}"
          onchange="pfUpdatePath('${f.key}',this.value)" onclick="event.stopPropagation()">
      </div>`;
    }
    return `<div class="pf-path-row">
      <span class="pf-path-label">${f.label}</span>
      <span class="pf-path-value">${val || '<span style="opacity:0.35">—</span>'}</span>
    </div>`;
  }).join('');
  const pathSection = `<div class="pf-paths-section">${pathRows}</div>`;

  // ── Email-Templates Sektion ──────────────────────────────────────
  const mailTplPath = paths['mail_templates'] || '';
  const mailPathRow = _pfEditMode
    ? `<div class="pf-path-row" style="margin-bottom:6px">
        <span class="pf-path-label">Ordner</span>
        <input class="pf-path-input" value="${mailTplPath}" placeholder="L:\\lgroup\\Pruefstd\\13_TeSLA\\Mail-Templates"
          onchange="pfUpdatePath('mail_templates',this.value)" onclick="event.stopPropagation()">
      </div>`
    : `<div class="pf-path-row" style="margin-bottom:6px">
        <span class="pf-path-label">Ordner</span>
        <span class="pf-path-value">${mailTplPath || '<span style="opacity:0.35">—</span>'}</span>
      </div>`;
  const emailTpls = tpl.email_templates || [];
  let emailRows = mailPathRow + emailTpls.map((et, i) => {
    if (_pfEditMode) {
      return `<div class="pf-email-row">
        <input class="pf-email-input lbl"  value="${et.label||''}" placeholder="Label"  onchange="pfUpdateEmailTpl(${i},'label',this.value)" onclick="event.stopPropagation()">
        <input class="pf-email-input file" value="${et.file||''}"  placeholder="*.oft"  onchange="pfUpdateEmailTpl(${i},'file',this.value)"  onclick="event.stopPropagation()">
        <button class="pf-email-del" onclick="pfDelEmailTpl(${i})" title="Entfernen">✕</button>
      </div>`;
    }
    return `<div class="pf-email-row">
      <span class="pf-path-value" style="flex:1">${et.label||'—'}</span>
      <span class="pf-path-value" style="opacity:0.55">${et.file||''}</span>
    </div>`;
  }).join('');
  if (_pfEditMode) emailRows += `<button class="pf-email-add" onclick="pfAddEmailTpl()">+ Template</button>`;
  const emailSection = `<div class="pf-email-section"><div class="pf-email-heading">Email-Templates</div>${emailRows}</div>`;

  // ── Folder-Buttons Sektion ──────────────────────────────────────
  const folders = tpl.folders || [];
  let folderRows = folders.map((f, i) => {
    if (_pfEditMode) {
      return `<div class="pf-folder-row">
        <span class="pf-folder-dot-preview"></span>
        <input class="pf-email-input lbl"  value="${f.label||''}" placeholder="Label"
          onchange="pfUpdateFolder(${i},'label',this.value)" onclick="event.stopPropagation()">
        <input class="pf-email-input file pf-folder-path" value="${f.path==='#'?'':f.path||''}" placeholder="Pfad (leer = ausgeblendet)"
          onchange="pfUpdateFolder(${i},'path',this.value)" onclick="event.stopPropagation()">
        <button class="pf-email-del" onclick="pfDelFolder(${i})" title="Entfernen">✕</button>
      </div>`;
    }
    const dotStyle = f.path && f.path !== '#' && f.path !== ''
      ? 'background:rgba(59,130,246,0.30);border-color:rgba(59,130,246,0.65)'
      : 'background:rgba(180,40,40,0.10);border-color:rgba(180,40,40,0.18)';
    const pathDisplay = (!f.path || f.path === '#')
      ? '<span style="opacity:0.30">— noch nicht konfiguriert —</span>'
      : `<span class="pf-path-value">${f.path}</span>`;
    return `<div class="pf-folder-row">
      <span class="pf-folder-dot-preview" style="${dotStyle}"></span>
      <span class="pf-path-value" style="flex:1;font-weight:600">${f.label||'—'}</span>
      ${pathDisplay}
    </div>`;
  }).join('');
  if (_pfEditMode) folderRows += `<button class="pf-email-add" onclick="pfAddFolder()">+ Ordner hinzufügen</button>`;
  const folderSection = `<div class="pf-email-section pf-folder-section"><div class="pf-email-heading">Ordner-Buttons</div>${folderRows}</div>`;

  const colorVal = tpl.color || '';
  const colorSection = _pfEditMode
    ? `<div class="pf-paths-section" style="margin-bottom:8px">
        <div class="pf-path-row">
          <span class="pf-path-label">Kachelfarbe</span>
          <input type="color" class="pf-color-input" value="${colorVal || '#e84646'}"
            oninput="pfUpdateColor(this.value)" onclick="event.stopPropagation()">
          <span class="pf-path-value" style="opacity:0.45;letter-spacing:0">Hintergrund der Kanban-Kacheln</span>
        </div>
      </div>`
    : colorVal
      ? `<div class="pf-paths-section" style="margin-bottom:8px">
          <div class="pf-path-row">
            <span class="pf-path-label">Kachelfarbe</span>
            <span class="pf-color-swatch" style="background:${colorVal}"></span>
            <span class="pf-path-value">${colorVal}</span>
          </div>
        </div>`
      : '';

  let html = colorSection + tardisSection + pathSection + folderSection + emailSection + (tpl.lanes||[]).map(lane => pfRenderLane(lane, {}, [])).join('');
  if (_pfEditMode) html += `<div class="pf-add-lane-row"><button class="pf-add-lane-btn" onclick="pfAddLane()">+ Lane hinzufügen</button></div>`;
  canvas.innerHTML = html;
  _pfInstanceEditMode = prevInstMode;
}

function hexToRgba(hex, alpha) {
  const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
  return `rgba(${r},${g},${b},${alpha})`;
}
function applyCardColor(card, color) {
  if (color) {
    card.style.background = hexToRgba(color, 0.08);
    card.style.boxShadow  = `inset 3px 0 0 ${hexToRgba(color, 0.65)}`;
  } else {
    card.style.background = '';
    card.style.boxShadow  = '';
  }
}
function kbRefreshCardColors() {
  document.querySelectorAll('.kb-card').forEach(card => {
    const tpl = _pfTemplates.find(t => t.engine_type === card.dataset.engineType);
    applyCardColor(card, tpl?.color);
  });
}
function pfUpdateColor(value) {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  tpl.color = value;
  pfSaveTemplate();
  kbRefreshCardColors();
}

function pfUpdateTardisProject(value) {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  tpl.tardis_project = value.trim();
  pfSaveTemplate();
}

function pfUpdatePath(key, value) {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  if (!tpl.paths) tpl.paths = {};
  tpl.paths[key] = value.trim();
  pfSaveTemplate();
}

function pfUpdateEmailTpl(idx, field, value) {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  if (!tpl.email_templates) tpl.email_templates = [];
  if (!tpl.email_templates[idx]) tpl.email_templates[idx] = {};
  tpl.email_templates[idx][field] = value.trim();
  pfSaveTemplate();
}

function pfAddEmailTpl() {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  if (!tpl.email_templates) tpl.email_templates = [];
  tpl.email_templates.push({ key: '', label: '', file: '' });
  pfSaveTemplate();
  pfRenderTemplateCanvas();
}

function pfDelEmailTpl(idx) {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  tpl.email_templates = (tpl.email_templates || []).filter((_, i) => i !== idx);
  pfSaveTemplate();
  pfRenderTemplateCanvas();
}

function pfUpdateFolder(idx, field, value) {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  if (!tpl.folders) tpl.folders = [];
  if (!tpl.folders[idx]) tpl.folders[idx] = { key: '', label: '', path: '' };
  tpl.folders[idx][field] = value.trim();
  if (field === 'label' && !tpl.folders[idx].key)
    tpl.folders[idx].key = value.trim().toLowerCase().replace(/\s+/g, '_');
  pfSaveTemplate();
  const engine = document.getElementById('kb-detail-engine')?.textContent.trim() || '';
  if (engine === tpl.engine_type) kbRenderFolderDots(engine);
}

function pfAddFolder() {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  if (!tpl.folders) tpl.folders = [];
  tpl.folders.push({ key: '', label: '', path: '' });
  pfSaveTemplate();
  pfRenderTemplateCanvas();
}

function pfDelFolder(idx) {
  const tpl = pfGetSelectedTpl(); if (!tpl) return;
  tpl.folders = (tpl.folders || []).filter((_, i) => i !== idx);
  pfSaveTemplate();
  pfRenderTemplateCanvas();
  const engine = document.getElementById('kb-detail-engine')?.textContent.trim() || '';
  if (engine === tpl.engine_type) kbRenderFolderDots(engine);
}

async function pfSelectTab(esn, wo) {
  _pfActiveTab = {esn, wo};
  const key = esn+'__'+wo;
  if (!_pfInstanceData[key]) {
    const d = await fetch('/api/process/instances/'+esn+'/'+wo).then(r=>r.json()).catch(()=>null);
    _pfInstanceData[key] = d || {esn,wo,template:'',step_status:{},extra_steps:[]};
  }
  pfCheckMilestones();
}

function pfGetInst() { return _pfActiveTab ? (_pfInstanceData[_pfActiveTab.esn+'__'+_pfActiveTab.wo]||null) : null; }
function pfGetTpl() {
  const i = pfGetInst(); if (!i) return null;
  // Use frozen snapshot if available — isolates instance from future template changes
  if (i.lanes_snapshot) return {engine_type:i.template, lanes:i.lanes_snapshot, milestones:i.milestones_snapshot||[]};
  return _pfTemplates.find(t=>t.engine_type===i.template)||null;
}
// Returns the lanes array + save function for the active edit mode
function pfGetEditTarget() {
  if (_pfInstanceEditMode) {
    const inst = pfGetInst(); if (!inst) return null;
    if (!inst.lanes_snapshot) inst.lanes_snapshot = JSON.parse(JSON.stringify(
      (_pfTemplates.find(t=>t.engine_type===inst.template)||{lanes:[]}).lanes||[]));
    return {lanes: inst.lanes_snapshot, save: pfSaveInstance};
  }
  // Template edit: use selected template in editor
  const tpl = pfGetSelectedTpl() || (_pfTemplates[0]||null); if (!tpl) return null;
  return {lanes: tpl.lanes, save: pfSaveTemplate};
}


function pfRenderLane(lane, ss, extra) {
  let procs = (lane.procedures||[]).map(p=>pfRenderProc(p,ss,false,lane.id)).join('');
  if (extra.length) procs += pfRenderProc({id:'extra_'+lane.id,label:'◈ Instanz-Steps',steps:extra},ss,true,lane.id);
  if (pfAnyEdit()) {
    procs += `<button class="pf-add-proc-btn" onclick="pfAddProc('${lane.id}')">+ Procedure</button>`;
    if (_pfInstanceEditMode)
      procs += `<button class="pf-add-proc-btn" onclick="pfAddExtraStep('${lane.id}')">+ Instanz-Step</button>`;
  }
  const laneLabel = pfAnyEdit()
    ? `<input class="pf-lane-input" value="${lane.label||''}" title="Lane umbenennen"
        onchange="pfRenameLane('${lane.id}',this.value)" onclick="event.stopPropagation()">`
    : `<span class="pf-lane-label">${lane.label||''}</span>`;
  const delBtn = pfAnyEdit()
    ? `<button class="pf-edit-micro del" title="Lane löschen" onclick="pfDelLane('${lane.id}')">🗑</button>` : '';
  return `<div class="pf-lane" id="pf-lane-${lane.id}">
    <div class="pf-lane-header">
      <span class="pf-lane-icon">${lane.icon||'◻'}</span>
      ${laneLabel}${delBtn}
    </div>
    <div class="pf-lane-body">${procs}</div>
  </div>`;
}

function pfRenderProc(proc, ss, isExtra, laneId) {
  const steps = (proc.steps||[]).map(s=>pfRenderStep(s, ss[s.id]||{}, isExtra||s._extra, laneId, proc.id)).join('');
  const editCtrl = (pfAnyEdit() && !isExtra)
    ? `<button class="pf-edit-micro" onclick="event.stopPropagation();pfAddStep('${laneId||''}','${proc.id}')">+</button>
       <button class="pf-edit-micro del" onclick="event.stopPropagation();pfDelProc('${laneId||''}','${proc.id}')">🗑</button>`
    : '';
  return `<div class="pf-procedure" id="pf-proc-${proc.id}">
    <div class="pf-procedure-header" onclick="pfToggleProc('${proc.id}')">
      <span>${proc.label||''}</span>${editCtrl}<span class="pf-procedure-toggle">▾</span>
    </div>
    <div class="pf-steps" id="pf-steps-${proc.id}">${steps}</div>
  </div>`;
}

function pfRenderStep(step, status, isExtra, laneId, procId) {
  const st   = status.status||'pending';
  const icon = PF_STATUS_ICON[st]||'◻';
  const typeIcon = step.type==='flow_trigger'?'⬢':(step.type==='hb_status'?'⇡':'');
  const extraCls = isExtra?' extra-step':'';
  let expanded = '';
  if (_pfEditMode) {
    const selType = (v,l) => `<option value="${v}" ${step.type===v?'selected':''}>${l}</option>`;
    const flowInput = step.type==='flow_trigger'
      ? pfFlowSelectHtml(step.id, step.linked_flow||'') : '';
    expanded = `<div class="pf-step-expanded">
      <input class="pf-step-edit-input" value="${step.label||''}" placeholder="Label"
        onchange="pfEditStepLabel('${step.id}',this.value)" onclick="event.stopPropagation()">
      <select class="pf-step-edit-sel" onchange="pfEditStepType('${step.id}',this.value)" onclick="event.stopPropagation()">
        ${selType('manual','◻ Manual')}${selType('flow_trigger','⬢ Flow Trigger')}${selType('hb_status','⇡ HB Status')}
      </select>
      ${flowInput}
      <button class="pf-act-btn err" onclick="event.stopPropagation();pfDelStep('${step.id}')">🗑 Löschen</button>
    </div>`;
  } else {
    let flowHtml = '';
    if (step.type === 'flow_trigger') {
      const _fl = (_pfFlowList||[]).find(f => f.uuid === step.linked_flow || f.name === step.linked_flow);
      const _flLabel = _fl ? _fl.name : (step.linked_flow || '(kein Flow)');
      flowHtml = `<div class="pf-step-flow-link">⬢ ${_flLabel}</div>`;
    }
    let actions = '';
    if (st!=='done'&&st!=='skipped')
      actions+=`<button class="pf-act-btn start" onclick="event.stopPropagation();pfSetStatus('${step.id}','running')">⟳ Start</button>`;
    actions+=`<button class="pf-act-btn done" onclick="event.stopPropagation();pfSetStatus('${step.id}','done')">✓ Done</button>`;
    if (st!=='pending')
      actions+=`<button class="pf-act-btn" onclick="event.stopPropagation();pfSetStatus('${step.id}','pending')">↩</button>`;
    if (step.type==='flow_trigger'&&step.linked_flow)
      actions+=`<button class="pf-act-btn" onclick="event.stopPropagation();pfTriggerFlow('${step.id}','${step.linked_flow}')">▶ Flow</button>`;
    const doneAt = status.done_at?`<div class="pf-step-done-at">${status.done_at}</div>`:'';
    expanded=`<div class="pf-step-expanded">${flowHtml}${doneAt}<div class="pf-step-actions">${actions}</div></div>`;
  }
  return `<div class="pf-step status-${st}${extraCls}" id="pf-step-${step.id}" onclick="pfToggleStep(event,'${step.id}')">
    <div class="pf-step-top">
      <span class="pf-step-status-icon">${icon}</span>
      <span class="pf-step-label">${step.label||''}</span>
      ${typeIcon?`<span class="pf-step-type">${typeIcon}</span>`:''}
    </div>
    ${expanded}
  </div>`;
}

function pfToggleStep(evt, stepId) {
  const el = document.getElementById('pf-step-'+stepId); if (!el) return;
  const wasOpen = el.classList.contains('open');
  document.querySelectorAll('.pf-step.open').forEach(s=>s.classList.remove('open'));
  if (!wasOpen) el.classList.add('open');
}

function pfToggleProc(procId) {
  const steps = document.getElementById('pf-steps-'+procId); if (!steps) return;
  const collapsed = steps.style.display==='none';
  steps.style.display = collapsed ? '' : 'none';
  const toggle = steps.previousElementSibling?.querySelector('.pf-procedure-toggle');
  if (toggle) toggle.textContent = collapsed ? '▾' : '▶';
}

async function pfSetStatus(stepId, newStatus) {
  if (!_pfActiveTab) return;
  const {esn,wo} = _pfActiveTab;
  const now = new Date().toISOString().slice(0,16).replace('T',' ');
  const body = {status:newStatus};
  if (newStatus==='done')    body.done_at    = now;
  if (newStatus==='running') body.started_at = now;
  if (newStatus==='pending') { body.done_at=''; body.started_at=''; }
  await fetch('/api/process/instances/'+esn+'/'+wo+'/step/'+stepId,
    {method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
  const key = esn+'__'+wo;
  if (!_pfInstanceData[key].step_status) _pfInstanceData[key].step_status={};
  Object.assign(_pfInstanceData[key].step_status[stepId]||(_pfInstanceData[key].step_status[stepId]={}), body);
  pfRenderActive();
  pfCheckMilestones();
  // Fortschritt neu berechnen und Kachel + Detail-Panel aktualisieren
  const _pInst = _pfInstanceData[key];
  const _pTpl  = _pInst.lanes_snapshot
    ? {lanes: _pInst.lanes_snapshot}
    : _pfTemplates.find(t => t.engine_type === _pInst.template);
  if (_pInst && _pTpl) kbUpdateCardProgress(esn, wo, pfCalcProgress(_pInst, _pTpl));
}

function pfCheckMilestones() {
  // Automatisches Verschieben von Kacheln deaktiviert
}

// ── Progress calculation ──────────────────────────────────────────
function pfCalcProgress(inst, tplData) {
  const ss    = inst.step_status || {};
  const lanes = (tplData && tplData.lanes) || [];
  let total = 0, done = 0;
  lanes.forEach(lane => {
    (lane.procedures || []).forEach(proc => {
      (proc.steps || []).forEach(step => {
        total++;
        if ((ss[step.id] || {}).status === 'done') done++;
      });
    });
  });
  (inst.extra_steps || []).forEach(step => {
    total++;
    if ((ss[step.id] || {}).status === 'done') done++;
  });
  return total > 0 ? Math.round((done / total) * 100) : 0;
}

function kbUpdateCardProgress(esn, wo, pct) {
  const CIRC   = 56.55;
  const offset = (CIRC * (1 - pct / 100)).toFixed(2);
  const label  = pct + '%';
  // Kachel im Board
  document.querySelectorAll('.kb-card').forEach(card => {
    if (card.dataset.esn === String(esn) && card.dataset.workorder === String(wo)) {
      const fg  = card.querySelector('.kb-progress-fg');
      const txt = card.querySelector('.kb-progress-txt');
      if (fg)  fg.style.strokeDashoffset = offset;
      if (txt) txt.textContent = label;
    }
  });
  // Großer Kreis im Detail-Panel (falls diese Kachel gerade offen ist)
  if (document.getElementById('kb-detail-esn')?.textContent === String(esn) &&
      document.getElementById('kb-detail-wo')?.textContent  === String(wo)) {
    const pfg    = document.getElementById('kb-detail-pfg');
    const pctEl  = document.getElementById('kb-detail-pct');
    if (pfg)   pfg.style.strokeDashoffset = offset;
    if (pctEl) pctEl.textContent  = label;
  }
}

function pfAdvanceKanban(esn, column) {
  const target = document.getElementById('kb-cards-'+column); if (!target) return;
  document.querySelectorAll('.kb-card').forEach(card=>{
    const esnEl = card.querySelector('.kb-card-esn');
    if (esnEl&&esnEl.textContent.includes(esn)&&card.parentElement!==target) {
      target.appendChild(card); kbUpdateCounts();
      card.style.background='rgba(34,197,94,0.22)';
      setTimeout(()=>{ card.style.background=''; },900);
    }
  });
}

function pfCloseFlowModal() {
  document.getElementById('pftm-backdrop').style.display = 'none';
  document.getElementById('pftm').style.display = 'none';
}

// Pending flow run state (avoids JSON-in-onclick issues)
let _pfPendingFlowRun = null;

async function pfTriggerFlow(stepId, flowRef) {
  const modal    = document.getElementById('pftm');
  const backdrop = document.getElementById('pftm-backdrop');
  const titleEl  = document.getElementById('pftm-title');
  const bodyEl   = document.getElementById('pftm-body');
  modal.querySelector('.pftm-footer')?.remove();

  titleEl.textContent = '⬢ Flow';
  bodyEl.innerHTML = `<div class="pftm-loading"><div class="pftm-spinner"></div><span>Flow wird geladen…</span></div>`;
  modal.style.display = 'flex';
  backdrop.style.display = 'block';

  // ── 1. Find flow by UUID or name (fallback) ───────────────────────
  let flowFile = null, flowName = flowRef;
  try {
    const list = await fetch('/api/flow/list').then(r=>r.json());
    const match = list.find(f => f.uuid === flowRef || f.name === flowRef || f.file === flowRef);
    if (match) { flowFile = match.file; flowName = match.name; }
  } catch(e) {}

  if (!flowFile) {
    bodyEl.innerHTML = `<div class="pftm-err-banner">Flow <b>${flowRef}</b> nicht gefunden.</div>`;
    _pfAddModalFooter(bodyEl);
    return;
  }
  titleEl.textContent = '⬢ ' + flowName;

  let flowDef = null;
  try {
    flowDef = await fetch('/api/flow/load/'+encodeURIComponent(flowFile)).then(r=>r.json());
  } catch(e) {}

  if (!flowDef?.nodes) {
    bodyEl.innerHTML = `<div class="pftm-err-banner">Flow konnte nicht geladen werden.</div>`;
    _pfAddModalFooter(bodyEl);
    return;
  }

  // Deep-copy so we don't mutate the cached flow def
  const nodes = JSON.parse(JSON.stringify(flowDef.nodes || []));
  const conns = JSON.parse(JSON.stringify(flowDef.connections || []));

  // ── 2. Context injection ──────────────────────────────────────────
  bodyEl.innerHTML = `<div class="pftm-loading"><div class="pftm-spinner"></div><span>Kontext wird injiziert…</span></div>`;
  const inst = pfGetInst();
  const injected = await pfInjectFlowContext(nodes, conns, inst?.esn||'', inst?.wo||'');

  // ── 3. Pre-flight check ───────────────────────────────────────────
  const blockers = pfGetPreflightBlockers(nodes);
  // Store original flowDef + esn/wo; re-inject fresh at run time (Bug 2 fix)
  _pfPendingFlowRun = {stepId, flowFile, flowDef, esn: inst?.esn||'', wo: inst?.wo||''};

  // Always show ratings/scans config for every cert_build node so the user
  // can verify (and correct) pre-saved selections before each run.
  const certNdsNeedConfig = flowDef.nodes.filter(nd => nd.typeId === 'cert_build');
  const runBlocked = certNdsNeedConfig.length > 0;

  let html = '';
  if (injected.length > 0) {
    html += `<div class="pftm-section-label">Injizierter Kontext</div>`;
    injected.forEach(it => {
      const cls = it.ok === false ? 'pending' : '';
      const ico = it.ok === false ? '⏳' : '✓';
      const detailRow = it.detail
        ? `<div class="pftm-ctx-detail">${it.detail}</div>`
        : '';
      html += `<div class="pftm-ctx-row ${cls}"><span class="pftm-ctx-ok">${ico}</span><span class="pftm-ctx-label">${it.label}</span><div style="flex:1;overflow:hidden"><span class="pftm-ctx-val" title="${it.value}">${it.value}</span>${detailRow}</div></div>`;
    });
  }
  if (blockers.length > 0) {
    html += `<div class="pftm-section-label" style="margin-top:8px">Offene Pflichtfelder</div>`;
    blockers.forEach(b => {
      html += `<div class="pftm-blocker-row">⚠ <b>${b.label}</b>: ${b.issue}</div>`;
    });
  }
  // Placeholders for cert inline config (populated via DOM after innerHTML)
  certNdsNeedConfig.forEach(nd => {
    html += `<div id="pftm-cert-${nd.id}"></div>`;
  });
  if (injected.length === 0 && blockers.length === 0 && !runBlocked) {
    html += `<div class="pftm-section-label">Bereit zur Ausführung</div>`;
  }
  html += `<div class="pftm-preflight-actions">
    <button class="pftm-btn" onclick="pfOpenFlowInEditor('${flowFile}')">✎ Im Editor öffnen</button>
    <button class="pftm-btn pftm-btn-primary" id="pftm-run-btn"${runBlocked ? ' disabled style="opacity:0.4"' : ''}>▶ Ausführen</button>
  </div>`;
  bodyEl.innerHTML = html;

  // Render cert inline config sections (pass injected nodes so th_test_id is available)
  certNdsNeedConfig.forEach(nd => {
    const el = document.getElementById('pftm-cert-' + nd.id);
    if (el) pfRenderCertBuildModalSection(nd, {nodes, connections: conns}, el);
  });
  // Enable run button immediately if all cert nodes already have valid selections
  pfUpdateRunBtnState();

  document.getElementById('pftm-run-btn')?.addEventListener('click', () => {
    if (_pfPendingFlowRun) pfRunPendingFlow();
  });
  _pfAddModalFooter(bodyEl);
}

async function pfRunPendingFlow() {
  if (!_pfPendingFlowRun) return;
  const {stepId, flowFile, flowDef, esn, wo} = _pfPendingFlowRun;
  _pfPendingFlowRun = null;
  const bodyEl = document.getElementById('pftm-body');
  document.getElementById('pftm').querySelector('.pftm-footer')?.remove();
  bodyEl.innerHTML = `<div class="pftm-loading"><div class="pftm-spinner"></div><span>Kontext wird injiziert…</span></div>`;

  // Fresh deep-copy + injection right before run (Bug 2 fix)
  const nodes = JSON.parse(JSON.stringify(flowDef.nodes || []));
  const conns = JSON.parse(JSON.stringify(flowDef.connections || []));
  await pfInjectFlowContext(nodes, conns, esn, wo, true);

  bodyEl.innerHTML = `<div class="pftm-loading"><div class="pftm-spinner"></div><span>Flow wird ausgeführt…</span></div>`;

  let results = null;
  try {
    const payload = {
      nodes: nodes.map(n=>({id:n.id, typeId:n.typeId, config:n.config})),
      connections: conns.map(c=>({fromNodeId:c.fromNodeId,fromPortIdx:c.fromPortIdx,toNodeId:c.toNodeId,toPortIdx:c.toPortIdx}))
    };
    results = await fetch('/api/flow/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
  } catch(e) {
    bodyEl.innerHTML = `<div class="pftm-err-banner">Ausführungsfehler: ${e.message||e}</div>`;
    _pfAddModalFooter(bodyEl);
    return;
  }

  // ── Render results ────────────────────────────────────────────────
  let html = '<div class="pftm-node-list">';
  let stepFinalStatus = 'running'; // Flow ausgeführt → Schritt auf START (nicht sofort DONE)

  nodes.forEach(nd => {
    const res = results[nd.id];
    if (!res) return;
    const ok = res.status === 'success';
    const label = nd.config?.title || nd.typeId || ('Node '+nd.id);
    const icon = ok ? '<span style="color:#22c55e">✓</span>' : '<span style="color:#e84646">✗</span>';
    let outHtml = '';

    if (!ok) {
      outHtml = `<div class="pftm-node-err-msg">${res.error||'Fehler'}</div>`;
      stepFinalStatus = 'error';
    } else if (res.output) {
      const o = res.output;
      if (o?.download_url) {
        outHtml = `<a class="pftm-dl-btn" href="${o.download_url}" target="_blank">⬇ Download</a>`;
        if (o._debug) {
          const dbg = o._debug;
          const rowsHtml = (dbg.mapping_resolved||[]).map(r => {
            const ok = r.found;
            const icon = ok ? '<span style="color:#22c55e">✓</span>' : '<span style="color:#e84646">✗</span>';
            const sc = r.scan_code ? `<small style="opacity:.6">[${r.scan_code}]</small>` : '';
            return `<tr>
              <td>${icon}</td>
              <td>${r.source}</td>
              <td>${r.source_field}${sc}</td>
              <td>${r.placeholder}</td>
              <td>${r.role}</td>
              <td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.value}">${r.value||'—'}</td>
            </tr>`;
          }).join('');
          const keysHtml = (dbg.flat_meta_keys||[]).map(k=>`<code>${k}</code>`).join(' ');
          const sp = dbg.special_roles||{};
          outHtml += `<details style="margin-top:8px;font-size:11px">
            <summary style="cursor:pointer;color:#aaa;user-select:none">🔍 Debug: Mapping Trace (${(dbg.mapping_resolved||[]).length} Einträge)</summary>
            <div style="margin-top:6px;padding:6px;background:#1a1a2e;border-radius:4px;overflow:auto">
              <div style="margin-bottom:4px;color:#888"><b>Special Roles:</b>
                trim_before=<code>${sp.trim_before||'—'}</code>
                trim_after=<code>${sp.trim_after||'—'}</code>
                engine_config=<code>${sp.engine_config||'—'}</code>
                pmux=<code>${sp.pmux||'—'}</code>
              </div>
              <table style="width:100%;border-collapse:collapse;font-size:10px">
                <thead><tr style="color:#666;border-bottom:1px solid #333">
                  <th></th><th>Source</th><th>Field [ScanCode]</th><th>Placeholder</th><th>Role</th><th>Value</th>
                </tr></thead>
                <tbody>${rowsHtml}</tbody>
              </table>
              <div style="margin-top:6px;color:#666"><b>flat_meta keys (${(dbg.flat_meta_keys||[]).length}):</b> ${keysHtml}</div>
            </div>
          </details>`;
        }
      } else if (nd.typeId === 'check' && o?.type === 'scan_check_result') {
        if (!o.overall_ok) stepFinalStatus = 'error'; // Check fail → Fehler
        outHtml = pfRenderCheckOutputHtml(o);
      } else if (Array.isArray(o?.columns) && Array.isArray(o?.rows)) {
        outHtml = pfRenderModalTableHtml(o);
      } else if (typeof o === 'object' && o !== null) {
        const preview = JSON.stringify(o).slice(0,160);
        const full    = JSON.stringify(o, null, 2);
        outHtml = `<details style="margin-top:3px">
          <summary class="pftm-node-out" style="cursor:pointer;list-style:none;user-select:none">${preview}${JSON.stringify(o).length>160?'…':''}</summary>
          <pre style="margin:4px 0 0;padding:6px 8px;background:rgba(0,0,0,0.06);border-radius:3px;font-size:8px;line-height:1.5;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all;color:rgba(60,10,10,0.75)">${full.replace(/</g,'&lt;')}</pre>
        </details>`;
      }
    }

    html += `<div class="pftm-node ${ok?'ok':'err'}">
      <div class="pftm-node-top">
        <span class="pftm-node-icon">${icon}</span>
        <span class="pftm-node-label">${label}</span>
        <span class="pftm-node-status">${nd.typeId}</span>
      </div>${outHtml}
    </div>`;
  });
  html += '</div>';
  bodyEl.innerHTML = html;
  _pfAddModalFooter(bodyEl);

  // Update step: last_run badge + status
  const now = new Date().toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
  const instNow = pfGetInst();
  if (instNow) {
    if (!instNow.step_status) instNow.step_status = {};
    if (!instNow.step_status[stepId]) instNow.step_status[stepId] = {};
    instNow.step_status[stepId].last_run = {status: stepFinalStatus, ts: now};
  }
  pfSetStatus(stepId, stepFinalStatus); // saves + re-renders (includes last_run)
}

function pfNormalizeWo(wo) {
  return (wo || '').replace(/\./g, '');
}

// ── Context injection: three passes ──────────────────────────────────
// Pass 1: hb_read node → connected tardis_query (existing path)
// Pass 2: standalone tardis_query without channelgroupId → resolve via Process Template tardis_project
// Pass 3: pdf_source node → WRB file path from KB documents (only at run time)
async function pfInjectFlowContext(nodes, conns, esn, wo, atRun = false) {
  const injected = [];
  if (!esn && !wo) return injected;
  const woN = pfNormalizeWo(wo);

  // Pass 1 — hb_read / hb_patch → connected tardis_query
  const hbInjectedTardisIds = new Set();
  for (const nd of nodes) {
    if (nd.typeId !== 'hb_read' && nd.typeId !== 'hb_patch') continue;
    // hb_patch: nur wenn Input 0 nicht verbunden ist
    if (nd.typeId === 'hb_patch' && conns.some(c => c.toNodeId === nd.id && c.toPortIdx === 0)) continue;
    const hbRes = await fetch(`/api/hb/find_order?esn=${encodeURIComponent(esn)}&wo=${encodeURIComponent(woN)}`)
      .then(r=>r.json()).catch(()=>null);
    if (hbRes && !hbRes.error) {
      nd.config.orderId = hbRes.order_id;
      injected.push({label: 'HB Read', value: `Order ${hbRes.order_id}  (ESN ${hbRes.esn} / WO ${hbRes.wo})`});
    } else {
      injected.push({label: 'HB Read', value: hbRes?.error || 'Order nicht gefunden', ok: false});
    }

    const tardisNd = _pfFindConnectedTardis(nd.id, nodes, conns);
    if (!tardisNd) continue;

    try {
      const match = await fetch(`/api/tardis/find_test?esn=${encodeURIComponent(esn)}&wo=${encodeURIComponent(woN)}`)
        .then(r=>r.json()).catch(()=>null);
      if (!match || match.error) {
        injected.push({label: 'TARDIS Query', value: match?.error||'Kein Test gefunden', ok: false});
        continue;
      }
      const steps = await fetch(`/api/tardis/teststeps?test_id=${encodeURIComponent(match.test_id)}`).then(r=>r.json()).catch(()=>[]);
      const step  = steps.find(s=>s.name?.includes('LUD')) || steps[0];
      if (!step) { injected.push({label:'TARDIS Query', value:'Kein TestStep (LUD)', ok:false}); continue; }

      const meas = await fetch(`/api/tardis/measurements?teststep_id=${encodeURIComponent(step.id)}`).then(r=>r.json()).catch(()=>[]);
      const m    = meas.find(x=>x.name?.toLowerCase().includes('absolute')) || meas[0];
      if (!m) { injected.push({label:'TARDIS Query', value:'Kein Measurement', ok:false}); continue; }

      const cgs = await fetch(`/api/tardis/channelgroups?measurement_id=${encodeURIComponent(m.id)}`).then(r=>r.json()).catch(()=>[]);
      const cg  = cgs[0];
      if (!cg) { injected.push({label:'TARDIS Query', value:'Keine ChannelGroup', ok:false}); continue; }

      tardisNd.config.th_project        = match.project;
      tardisNd.config.th_pool           = match.pool;
      tardisNd.config.th_test_id        = match.test_id;
      tardisNd.config.th_teststep_id    = step.id;
      tardisNd.config.th_measurement_id = m.id;
      tardisNd.config.channelgroupId    = cg.id;
      hbInjectedTardisIds.add(tardisNd.id);
      injected.push({label: 'TARDIS Query', value: `${match.test_name}  ▸  CG ${cg.id}`});
    } catch(e) {
      injected.push({label:'TARDIS Query', value: e.message||'Fehler', ok:false});
    }
  }

  // Pass 2 — standalone tardis_query (not reached via hb_read, no channelgroupId configured)
  const inst = pfGetInst();
  const engType = inst?.kb_engine_type || '';
  const procTpl = _pfTemplates?.find(t => t.engine_type === engType);
  const tardisProject = procTpl?.tardis_project || '';

  for (const nd of nodes) {
    if (nd.typeId !== 'tardis_query') continue;
    if (hbInjectedTardisIds.has(nd.id)) continue;        // already handled by Pass 1
    if (nd.config?.channelgroupId) continue;             // already configured — don't overwrite

    if (!tardisProject) {
      injected.push({label: 'TARDIS Query', value: `Kein TARDIS Projekt in Process Template (${engType || '?'}) hinterlegt`, ok: false});
      continue;
    }
    if (!esn) {
      injected.push({label: 'TARDIS Query', value: 'ESN fehlt — Kontext nicht injizierbar', ok: false});
      continue;
    }

    try {
      const ctx = await fetch(
        `/api/tardis/resolve_context?esn=${encodeURIComponent(esn)}&wo=${encodeURIComponent(woN)}&project=${encodeURIComponent(tardisProject)}`
      ).then(r=>r.json()).catch(()=>null);

      if (!ctx || ctx.error) {
        injected.push({label: 'TARDIS Query', value: ctx?.error || 'Kontext-Auflösung fehlgeschlagen', ok: false});
        continue;
      }

      nd.config.th_project        = ctx.th_project;
      nd.config.th_pool           = ctx.th_pool;
      nd.config.th_test_id        = ctx.th_test_id;
      nd.config.th_teststep_id    = ctx.th_teststep_id;
      nd.config.th_measurement_id = ctx.th_measurement_id;
      nd.config.channelgroupId    = ctx.channelgroupId;
      injected.push({
        label: 'TARDIS Query',
        value: `${ctx.test_name}  ▸  ${ctx.cg_name}`,
        detail: `${ctx.th_project} / ${ctx.th_pool} / ${ctx.step_name} / ${ctx.meas_name}`
      });
    } catch(e) {
      injected.push({label: 'TARDIS Query', value: e.message||'Fehler', ok: false});
    }
  }

  // Pass 3 — pdf_source nodes: inject WRB file path (run time only)
  const pdfSourceNodes = nodes.filter(nd => nd.typeId === 'pdf_source');
  if (pdfSourceNodes.length > 0) {
    if (!atRun) {
      // Modal-preview: just show a pending indicator, no fetch yet
      pdfSourceNodes.forEach(() => {
        injected.push({label: 'WRB PDF', value: 'Pfad wird beim Ausführen aufgelöst', ok: null});
      });
    } else {
      // Run time: fetch WRB path and inject
      const instNow = pfGetInst();
      const engType = instNow?.kb_engine_type || '';
      try {
        const params = new URLSearchParams({
          wo: woN || '', esn: esn || '', engine_type: engType
        });
        const docs = await fetch('/api/kb/documents?' + params).then(r => r.json()).catch(() => null);
        const wrbPath = docs?.wrb?.file_path || null;
        pdfSourceNodes.forEach(nd => {
          if (wrbPath) {
            nd.config.filePath = wrbPath;
            injected.push({label: 'WRB PDF', value: docs.wrb.file_name || wrbPath});
          } else {
            injected.push({label: 'WRB PDF', value: 'Nicht gefunden — Pfad bleibt leer', ok: false});
          }
        });
      } catch(e) {
        pdfSourceNodes.forEach(() =>
          injected.push({label: 'WRB PDF', value: e.message || 'Fehler', ok: false})
        );
      }
    }
  }

  return injected;
}

function _pfFindConnectedTardis(hbNodeId, nodes, conns) {
  // Path A: HB Read → TARDIS Query direkt
  const direct = conns.find(c => c.fromNodeId===hbNodeId &&
    nodes.find(n=>n.id===c.toNodeId)?.typeId==='tardis_query');
  if (direct) return nodes.find(n=>n.id===direct.toNodeId);
  // Path B: HB Read → Cert Build (port 2) → TARDIS Query (port 1)
  const cbConn = conns.find(c=>c.fromNodeId===hbNodeId && c.toPortIdx===2);
  if (cbConn) {
    const tConn = conns.find(c=>c.toNodeId===cbConn.toNodeId && c.toPortIdx===1);
    if (tConn) return nodes.find(n=>n.id===tConn.fromNodeId);
  }
  return null;
}

function pfGetPreflightBlockers(nodes) {
  // cert_build nodes are handled inline in the modal — not hard blockers
  return [];
}

// ── Cert Build inline config in pre-flight modal ─────────────────────

function pfUpdateRunBtnState() {
  if (!_pfPendingFlowRun) return;
  const allReady = _pfPendingFlowRun.flowDef.nodes
    .filter(nd => nd.typeId === 'cert_build')
    .every(nd => (nd.config?.selected_ratings?.length > 0) &&
                 Object.keys(nd.config?.selected_scans||{}).length > 0);
  const btn = document.getElementById('pftm-run-btn');
  if (btn) { btn.disabled = !allReady; btn.style.opacity = allReady ? '1' : '0.4'; }
}

function pfRenderCertBuildModalSection(certNd, flowDef, container) {
  const recipeConn = flowDef.connections?.find(c => c.toNodeId === certNd.id && c.toPortIdx === 0);
  const recipeNode = recipeConn ? flowDef.nodes.find(n => n.id === recipeConn.fromNodeId) : null;
  const engineType = recipeNode?.config?.engineType || '';

  const tardisConn = flowDef.connections?.find(c => c.toNodeId === certNd.id && c.toPortIdx === 1);
  const tardisNode = tardisConn ? flowDef.nodes.find(n => n.id === tardisConn.fromNodeId) : null;
  const testId     = tardisNode?.config?.th_test_id || '';

  const sec = document.createElement('div');
  sec.className = 'pftm-cert-section';
  const titleDiv = document.createElement('div');
  titleDiv.className = 'pftm-cert-title';
  titleDiv.textContent = '◈ ' + (certNd.config?.title || 'Cert Build');
  sec.appendChild(titleDiv);

  if (engineType || certNd.config?.certTypeName) {
    const subDiv = document.createElement('div');
    subDiv.style.cssText = 'font-size:9px;opacity:0.6;margin-top:2px;margin-bottom:4px;';
    const parts = [engineType, certNd.config?.certTypeName].filter(Boolean);
    subDiv.textContent = parts.join(' · ');
    sec.appendChild(subDiv);
  }

  if (!engineType) {
    const hint = document.createElement('div');
    hint.style.cssText = 'font-size:9px;opacity:0.5;font-style:italic;';
    hint.textContent = 'Recipe-Node nicht verbunden — im Editor konfigurieren.';
    sec.appendChild(hint);
    container.appendChild(sec);
    return;
  }

  // ── Ratings ───────────────────────────────────────────────────────
  const ratingSubLabel = document.createElement('div');
  ratingSubLabel.className = 'pftm-cert-sublabel';
  ratingSubLabel.textContent = 'RATINGS';
  sec.appendChild(ratingSubLabel);

  const quickRow = document.createElement('div');
  quickRow.style.cssText = 'display:flex;gap:4px;margin-bottom:6px;';
  sec.appendChild(quickRow);

  const chipWrap = document.createElement('div');
  chipWrap.className = 'pftm-cert-chips';
  sec.appendChild(chipWrap);

  fetch('/api/recipes').then(r => r.json()).then(recipes => {
    const rec = recipes.find(r => r.engine_type === engineType);
    const ratings = rec?.ratings || [];
    if (!Array.isArray(certNd.config.selected_ratings)) certNd.config.selected_ratings = [];

    ['Alle','Keine'].forEach(label => {
      const btn = document.createElement('button');
      btn.className = 'pftm-cert-chip';
      btn.style.borderRadius = '3px';
      btn.textContent = label;
      btn.addEventListener('click', () => {
        certNd.config.selected_ratings = label === 'Alle' ? ratings.map(r => r.name) : [];
        refreshChips();
        pfUpdateRunBtnState();
      });
      quickRow.appendChild(btn);
    });

    function refreshChips() {
      chipWrap.innerHTML = '';
      ratings.forEach(rt => {
        const active = (certNd.config.selected_ratings || []).includes(rt.name);
        const chip = document.createElement('button');
        chip.className = 'pftm-cert-chip' + (active ? ' active' : '');
        chip.textContent = rt.name;
        chip.addEventListener('click', () => {
          if (!certNd.config.selected_ratings) certNd.config.selected_ratings = [];
          if (active) {
            certNd.config.selected_ratings = certNd.config.selected_ratings.filter(r => r !== rt.name);
          } else {
            certNd.config.selected_ratings.push(rt.name);
          }
          refreshChips();
          pfUpdateRunBtnState();
        });
        chipWrap.appendChild(chip);
      });
    }
    refreshChips();
  }).catch(() => {
    const err = document.createElement('div');
    err.style.cssText = 'font-size:9px;color:#ef4444;';
    err.textContent = 'Fehler beim Laden des Recipes.';
    sec.appendChild(err);
  });

  // ── Scans ─────────────────────────────────────────────────────────
  const scanSubLabel = document.createElement('div');
  scanSubLabel.className = 'pftm-cert-sublabel';
  scanSubLabel.textContent = 'SCANS';
  sec.appendChild(scanSubLabel);

  if (!testId) {
    const hint = document.createElement('div');
    hint.style.cssText = 'font-size:9px;opacity:0.45;font-style:italic;';
    hint.textContent = 'Test-ID fehlt — TARDIS Query verbinden oder Kontext injizieren.';
    sec.appendChild(hint);
  } else {
    const checkBtn = document.createElement('button');
    checkBtn.className = 'pftm-cert-scan-btn';
    checkBtn.textContent = '⟳ Scans prüfen';
    sec.appendChild(checkBtn);

    const scanTable = document.createElement('div');
    scanTable.style.cssText = 'font-size:10px;';
    sec.appendChild(scanTable);

    if (certNd.config._scanCheckData) {
      pfRenderCertScanTable(certNd, scanTable, certNd.config._scanCheckData);
    }

    checkBtn.addEventListener('click', async () => {
      checkBtn.disabled = true; checkBtn.textContent = '⟳ Lade…';
      try {
        const url = `/api/flow/batch_scan_check?test_id=${encodeURIComponent(testId)}&engine_type=${encodeURIComponent(engineType)}`;
        const data = await fetch(url).then(r => r.json());
        if (data.error) throw new Error(data.error);
        certNd.config._scanCheckData = data;
        pfRenderCertScanTable(certNd, scanTable, data);
        pfUpdateRunBtnState();
      } catch(e) {
        scanTable.style.color = '#ef4444';
        scanTable.textContent = 'Fehler: ' + e.message;
      } finally {
        checkBtn.disabled = false; checkBtn.textContent = '⟳ Scans prüfen';
      }
    });
    // Auto-load scans when modal opens so best scan is pre-selected immediately
    checkBtn.click();
  }

  container.appendChild(sec);
}

function pfRenderCertScanTable(certNd, container, data) {
  container.innerHTML = '';
  if (!certNd.config.selected_scans) certNd.config.selected_scans = {};
  const codes = Object.keys(data).filter(k => k !== '_meta');
  if (!codes.length) {
    container.style.color = 'rgba(255,255,255,0.35)';
    container.textContent = 'Keine Scan-Daten gefunden.';
    return;
  }
  codes.forEach(code => {
    const codeData = data[code];
    const scanNrs = Object.keys(codeData).sort((a, b) => parseFloat(a) - parseFloat(b));
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:5px;flex-wrap:wrap;';
    const lbl = document.createElement('span');
    lbl.style.cssText = 'font-size:9px;color:rgba(80,10,10,0.45);width:66px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
    lbl.textContent = code; lbl.title = code;
    row.appendChild(lbl);
    if (!scanNrs.length) {
      delete certNd.config.selected_scans[code];
      const empty = document.createElement('span');
      empty.style.cssText = 'font-size:9px;color:rgba(80,10,10,0.30);font-style:italic;';
      empty.textContent = '(keine Daten)';
      row.appendChild(empty);
      container.appendChild(row);
      return;
    }
    const _prevSel = certNd.config.selected_scans[code];
    if (_prevSel && !scanNrs.includes(String(_prevSel))) {
      delete certNd.config.selected_scans[code];  // stale selection from old test/saved flow
    }
    if (!certNd.config.selected_scans[code]) {
      const best = scanNrs.slice().reverse().find(nr => codeData[nr]?.__ok) || scanNrs[scanNrs.length - 1];
      certNd.config.selected_scans[code] = best;
    }
    scanNrs.forEach(nr => {
      const isOk     = codeData[nr]?.__ok !== false;
      const isActive = String(certNd.config.selected_scans[code]) === String(nr);
      const btn = document.createElement('button');
      btn.style.cssText = [
        'font-size:9px;padding:2px 7px;border-radius:3px;cursor:pointer;border:1px solid;',
        'background:' + (isOk ? 'rgba(34,197,94,0.10)' : 'rgba(192,57,43,0.10)') + ';',
        'border-color:' + (isActive ? (isOk ? '#166534' : '#b91c1c') : (isOk ? 'rgba(34,197,94,0.45)' : 'rgba(192,57,43,0.35)')) + ';',
        'color:' + (isActive ? (isOk ? '#166534' : '#b91c1c') : (isOk ? '#166534' : 'rgba(80,10,10,0.50)')) + ';',
        'font-weight:' + (isActive ? '700' : '400') + ';',
      ].join('');
      btn.textContent = nr + (isOk ? ' ✓' : ' ✗');
      btn.title = (codeData[nr]?.__violations || []).join(', ') || 'OK';
      btn.addEventListener('click', () => {
        certNd.config.selected_scans[code] = nr;
        pfRenderCertScanTable(certNd, container, data);
        pfUpdateRunBtnState();
      });
      row.appendChild(btn);
    });
    container.appendChild(row);
  });
}

function pfRenderCheckOutputHtml(o) {
  const results   = o.results   || {};
  const allScans  = o.all_scans || {};
  const bestScans = o.best_scans || {};
  const overallCls = o.overall_ok ? 'pftm-overall-ok' : 'pftm-overall-err';
  const overallTxt = o.overall_ok ? '✓ Alle Scans OK' : '✗ Scan-Prüfung fehlgeschlagen';

  // ── bestehende Pills (gewählter Scan pro Code) ──
  let pillsHtml = '';
  Object.entries(results).forEach(([code, r]) => {
    const cls = r.ok ? 'ok' : 'err';
    const tip = r.violations?.length ? r.violations.join('\n') : 'OK';
    pillsHtml += `<span class="pftm-pill ${cls}" title="${tip}">${code} #${r.scan_nr}</span>`;
  });

  // ── alle Scans pro Code (wie Config-Panel) ──
  let allScansHtml = '';
  Object.entries(allScans).forEach(([code, scans]) => {
    const selectedNr = String(bestScans[code] || (results[code]?.scan_nr ?? ''));
    const nrs = Object.keys(scans).sort((a, b) => parseFloat(a) - parseFloat(b));
    const chips = nrs.map(nr => {
      const ok  = scans[nr]?.ok !== false;
      const sel = String(nr) === selectedNr;
      const tip = (scans[nr]?.violations || []).join(', ') || 'OK';
      const cls = (ok ? 'ok' : 'err') + (sel ? ' selected' : '');
      return `<span class="pftm-scan-chip ${cls}" title="${tip}">#${nr}${sel ? ' ★' : ''}</span>`;
    }).join('');
    allScansHtml += `<div class="pftm-allscans-row">
      <span class="pftm-allscans-code" title="${code}">${code}</span>${chips}
    </div>`;
  });

  return `<div class="${overallCls}">${overallTxt}</div>
    <div class="pftm-check-pills">${pillsHtml||'—'}</div>
    ${allScansHtml ? `<div class="pftm-allscans">${allScansHtml}</div>` : ''}`;
}

function pfRenderModalTableHtml(o) {
  if (!Array.isArray(o?.columns) || !Array.isArray(o?.rows)) return '';
  const cols    = o.columns.filter(c=>!String(c).startsWith('__'));
  const colIdxs = cols.map(c=>o.columns.indexOf(c));
  const rows    = o.rows.slice(0,50);
  const thHtml  = cols.map(c=>`<th>${c}</th>`).join('');
  const tbHtml  = rows.map(r=>`<tr>${colIdxs.map(i=>`<td>${r[i]!=null?r[i]:''}</td>`).join('')}</tr>`).join('');
  const info    = o.rows.length > 50
    ? `${o.rows.length} Zeilen · ${cols.length} Spalten (erste 50 angezeigt)`
    : `${o.rows.length} Zeilen · ${cols.length} Spalten`;
  return `<div class="pftm-tbl-wrap"><table class="pftm-tbl"><thead><tr>${thHtml}</tr></thead><tbody>${tbHtml}</tbody></table></div><div class="pftm-tbl-info">${info}</div>`;
}

function pfOpenFlowInEditor(flowFile) {
  pfCloseFlowModal();
  const inst = pfGetInst();
  const esn  = inst?.esn || '';
  const wo   = inst?.wo  || '';
  let url = '/flow?open=' + encodeURIComponent(flowFile);
  if (esn) url += '&esn=' + encodeURIComponent(esn);
  if (wo)  url += '&wo='  + encodeURIComponent(wo);
  window.open(url, '_blank');
}

function _pfAddModalFooter(bodyEl) {
  const modal = document.getElementById('pftm');
  modal.querySelector('.pftm-footer')?.remove();
  const footer = document.createElement('div');
  footer.className = 'pftm-footer';
  footer.innerHTML = '<button class="pftm-btn" onclick="pfCloseFlowModal()">Schließen</button>';
  modal.appendChild(footer);
}

async function pfToggleTemplateEdit() {
  _pfEditMode = !_pfEditMode;
  if (_pfEditMode) {
    _pfInstanceEditMode = false;
    if (_pfFlowList === null) await pfLoadFlowList();
  }
  document.getElementById('pf-edit-tpl-btn')?.classList.toggle('active', _pfEditMode);
  pfTplPopulateSel();
  pfRenderTemplateCanvas();
}
async function pfToggleInstanceEdit() {
  _pfInstanceEditMode = !_pfInstanceEditMode;
  if (_pfInstanceEditMode) {
    _pfEditMode = false;
    if (_pfFlowList === null) await pfLoadFlowList();
  }
  document.getElementById('pf-edit-inst-btn')?.classList.toggle('active', _pfInstanceEditMode);
  // Re-render sidebar swimlane
  const inst = pfGetInst();
  if (inst) kbLoadDetailPf(inst.esn, inst.wo, inst.template);
}


async function pfCreateInstanceData(esn, wo, tplName, kbExtra) {
  if (_pfInstances.find(i=>i.esn===esn&&i.wo===wo)) { pfSelectTab(esn,wo); return; }
  const srcTpl = _pfTemplates.find(t=>t.engine_type===tplName);
  const data = {
    esn, wo, template: tplName, label: tplName+' · '+esn,
    kb_status:      'planned',
    kb_engine_type: tplName,
    sap_engine:     kbExtra?.sap_engine     || '',
    kb_customer:    kbExtra?.kb_customer    || '',
    kb_engineer:    kbExtra?.kb_engineer    || '',
    kb_dates:       kbExtra?.kb_dates       || {},
    step_status: {}, extra_steps: [],
    lanes_snapshot:      JSON.parse(JSON.stringify((srcTpl||{lanes:[]}).lanes||[])),
    milestones_snapshot: JSON.parse(JSON.stringify((srcTpl||{milestones:[]}).milestones||[])),
    created_at: new Date().toISOString().slice(0,10),
  };
  await fetch('/api/process/instances/'+esn+'/'+wo,
    {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
  _pfInstanceData[esn+'__'+wo]=data;
  _pfInstances.push({esn,wo,template:tplName,label:data.label,kb_status:'planned',kb_engine_type:tplName,sap_engine:data.sap_engine});
  pfSelectTab(esn,wo);
}

// ── Render dispatcher ────────────────────────────────────────────
function pfRenderActive() {
  if (_pfEditMode) {
    pfRenderTemplateCanvas();
  } else {
    // Instance view/edit — reload sidebar if open
    const inst = pfGetInst();
    if (inst) kbLoadDetailPf(inst.esn, inst.wo, inst.template);
  }
}

// ── Persistence ──────────────────────────────────────────────────
async function pfSaveTemplate() {
  await fetch('/api/process/templates', {
    method:'POST', headers:{'Content-Type':'application/json'},
    body:JSON.stringify(_pfTemplates)
  });
}
async function pfSaveInstance() {
  const inst = pfGetInst(); if (!inst) return;
  await fetch('/api/process/instances/'+inst.esn+'/'+inst.wo, {
    method:'POST', headers:{'Content-Type':'application/json'},
    body:JSON.stringify(inst)
  });
}

async function pfReassignTemplate(esn, wo, newTplName) {
  if (!newTplName) return;
  const key = esn+'__'+wo;
  const inst = _pfInstanceData[key]; if (!inst) return;
  const srcTpl = _pfTemplates.find(t=>t.engine_type===newTplName); if (!srcTpl) return;
  inst.template            = newTplName;
  inst.kb_engine_type      = newTplName;
  // sap_engine deliberately not overwritten — preserves original SAP name
  inst.lanes_snapshot      = JSON.parse(JSON.stringify(srcTpl.lanes||[]));
  inst.milestones_snapshot = JSON.parse(JSON.stringify(srcTpl.milestones||[]));
  await fetch('/api/process/instances/'+esn+'/'+wo,
    {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(inst)});
  kbLoadDetailPf(esn, wo, newTplName);
}

// ── Step find/update helper ──────────────────────────────────────
function pfFindAndUpdateStep(stepId, updater) {
  const et = pfGetEditTarget(); if (!et) return false;
  for (const lane of (et.lanes||[])) {
    for (const proc of (lane.procedures||[])) {
      const s = (proc.steps||[]).find(x=>x.id===stepId);
      if (s) { updater(s); et.save(); return true; }
    }
  }
  const inst = pfGetInst();
  if (inst) {
    const es = (inst.extra_steps||[]).find(x=>x.id===stepId);
    if (es) { updater(es); pfSaveInstance(); return true; }
  }
  return false;
}
function pfEditStepLabel(stepId, value) { pfFindAndUpdateStep(stepId, s=>{ s.label=value; }); }
async function pfEditStepType(stepId, value) {
  pfFindAndUpdateStep(stepId, s=>{ s.type=value; });
  if (value === 'flow_trigger') await pfLoadFlowList();
  pfRenderActive();
}
function pfEditStepFlow(stepId, value)  { pfFindAndUpdateStep(stepId, s=>{ s.linked_flow=value; }); }

async function pfLoadFlowList() {
  _pfFlowList = await fetch('/api/flow/list').then(r => r.json()).catch(() => []);
}
function pfFlowSelectHtml(stepId, currentValue) {
  const flows = _pfFlowList || [];
  const placeholder = `<option value="">— Flow wählen —</option>`;
  let opts = flows.length === 0
    ? placeholder
    : placeholder + flows.map(f => {
        const val = f.uuid || f.name;
        const sel = (val === currentValue || f.name === currentValue) ? ' selected' : '';
        return `<option value="${val}"${sel}>${f.name}</option>`;
      }).join('');
  return `<select class="pf-step-edit-sel" onchange="pfEditStepFlow('${stepId}',this.value)" onclick="event.stopPropagation()">${opts}</select>`;
}

// ── Lane / Proc / Step CRUD ──────────────────────────────────────
function pfRenameLane(laneId, value) {
  const et = pfGetEditTarget(); if (!et) return;
  const lane = et.lanes.find(l=>l.id===laneId);
  if (lane) { lane.label=value; et.save(); }
}
function pfDelLane(laneId) {
  const et = pfGetEditTarget(); if (!et) return;
  et.lanes.splice(0, et.lanes.length, ...et.lanes.filter(l=>l.id!==laneId));
  et.save(); pfRenderActive();
}
function pfAddLane() {
  const et = pfGetEditTarget(); if (!et) return;
  et.lanes.push({id:'lane_'+Date.now(), label:'Neue Lane', icon:'◈', procedures:[]});
  et.save(); pfRenderActive();
}
function pfAddProc(laneId) {
  const et = pfGetEditTarget(); if (!et) return;
  const lane = et.lanes.find(l=>l.id===laneId); if (!lane) return;
  lane.procedures = [...(lane.procedures||[]), {id:'proc_'+Date.now(), label:'Neue Procedure', steps:[]}];
  et.save(); pfRenderActive();
}
function pfDelProc(laneId, procId) {
  const et = pfGetEditTarget(); if (!et) return;
  const lane = et.lanes.find(l=>l.id===laneId); if (!lane) return;
  lane.procedures = (lane.procedures||[]).filter(p=>p.id!==procId);
  et.save(); pfRenderActive();
}
function pfAddStep(laneId, procId) {
  const et = pfGetEditTarget(); if (!et) return;
  const lane = et.lanes.find(l=>l.id===laneId); if (!lane) return;
  const proc = (lane.procedures||[]).find(p=>p.id===procId); if (!proc) return;
  proc.steps = [...(proc.steps||[]), {id:'s_'+Date.now(), label:'Neuer Step', type:'manual'}];
  et.save(); pfRenderActive();
}
function pfDelStep(stepId) {
  const et = pfGetEditTarget(), inst = pfGetInst(); if (!et||!inst) return;
  for (const lane of et.lanes)
    for (const proc of (lane.procedures||[]))
      proc.steps = (proc.steps||[]).filter(s=>s.id!==stepId);
  inst.extra_steps = (inst.extra_steps||[]).filter(s=>s.id!==stepId);
  et.save(); pfSaveInstance(); pfRenderActive();
}
function pfAddExtraStep(laneId) {
  const inst = pfGetInst(); if (!inst) return;
  if (!inst.extra_steps) inst.extra_steps=[];
  inst.extra_steps.push({id:'es_'+Date.now(), label:'Instanz-Step', type:'manual', lane_id:laneId});
  pfSaveInstance(); pfRenderActive();
}

document.addEventListener('DOMContentLoaded', ()=>{ if(document.getElementById('pf-tpl-wrap')) pfInit(); });
</script>
</body>
</html>"""

PALANTIR_HTML = (PALANTIR_HTML
    .replace('__SIDEBAR_CSS__',  SIDEBAR_CSS)
    .replace('__SIDEBAR_JS__',   SIDEBAR_JS)
    .replace('__SIDEBAR_HTML__', sidebar_html('palantir'))
)


# ══════════════════════════════════════════════════════════════════
#  FAN PAGE  (/
# ══════════════════════════════════════════════════════════════════

HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Palantir Explained — Issue No 6</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  :root {
    --sb-bg: rgba(35,35,35,0.97);
    --sb-border: rgba(255,255,255,0.15);
    --sb-label: rgba(255,255,255,0.45);
    --sb-accent: #ffffff;
    --sb-hover: rgba(255,255,255,0.07);
    --sb-hover-border: rgba(255,255,255,0.30);
    --sb-active-bg: rgba(255,255,255,0.10);
    --sb-icon: rgba(255,255,255,0.50);
    --sb-num: rgba(255,255,255,0.30);
  }

  __SIDEBAR_CSS__

  body {
    background: #2d2d2d;
    display: flex;
    flex-direction: row;
    height: 100vh;
    overflow: hidden;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
  }

  #main {
    flex: 1;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  #header {
    flex-shrink: 0;
    padding: 22px 36px 18px;
    display: flex;
    align-items: flex-start;
    border-bottom: 1px solid rgba(255,255,255,0.22);
  }

  #card-wrap {
    flex: 1;
    display: flex;
    justify-content: center;
    align-items: center;
    overflow: hidden;
    padding: 12px 16px;
  }

  .header-left { display:flex; flex-direction:column; gap:4px; min-width:200px; }

  .series-label {
    font-size: 10px; letter-spacing: 0.14em;
    color: rgba(255,255,255,0.70); text-transform: uppercase; font-weight: 400;
  }

  .rfx-num {
    font-size: 64px; font-weight: 700; color: #ffffff;
    line-height: 0.88; letter-spacing: -0.03em;
  }

  .rfx-num span {
    font-size: 22px; font-weight: 300; vertical-align: top;
    padding-top: 8px; display: inline-block; letter-spacing: 0.01em;
  }

  .divider {
    width: 1px; background: rgba(255,255,255,0.22);
    margin: 0 32px; align-self: stretch;
  }

  .header-center { display:flex; flex-direction:column; justify-content:center; gap:10px; padding:4px 0; }

  .header-title {
    font-size: 20px; font-weight: 400; color: #ffffff;
    line-height: 1.25; letter-spacing: -0.01em; max-width: 420px;
  }

  .header-link {
    display: flex; align-items: center; gap: 8px;
    font-size: 10.5px; letter-spacing: 0.10em;
    color: rgba(255,255,255,0.65); text-transform: uppercase;
  }
  .header-link::before { content: '→'; font-size: 12px; }

  .header-right {
    margin-left: auto; display: flex; flex-direction: column;
    align-items: flex-end; justify-content: space-between; padding: 4px 0; gap: 24px;
  }

  .icon-circle { width: 28px; height: 28px; }

  .copyright { font-size: 10px; letter-spacing: 0.08em; color: rgba(255,255,255,0.50); }

  .card {
    display: flex;
    width: min(98%, 1360px);
    height: calc(100vh - 114px);
    background: #2d2d2d;
    overflow: hidden;
    position: relative;
    min-height: 0;
  }

  /* ── LEFT PANEL ─────────────────────────────────── */
  .left {
    flex: 0 0 42%;
    display: flex;
    flex-direction: column;
    padding: clamp(24px, 4%, 52px) clamp(28px, 5%, 60px);
    border-right: 1px solid rgba(255,255,255,0.22);
    position: relative;
  }

  .logo { margin-bottom: auto; }

  .meta {
    display: flex;
    align-items: center;
    gap: 14px;
    margin-top: clamp(18px, 3%, 32px);
    margin-bottom: clamp(24px, 4%, 44px);
  }

  .meta-text {
    font-size: clamp(8px, 0.9vw, 10.5px);
    font-weight: 500;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: rgba(255,255,255,0.55);
    white-space: nowrap;
  }

  .meta-text sup {
    font-size: 0.65em;
    vertical-align: super;
    letter-spacing: 0.05em;
  }

  .meta-sep {
    width: 1px;
    height: 18px;
    background: rgba(255,255,255,0.22);
    flex-shrink: 0;
  }

  .heading-wrap {
    flex: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }

  h1 {
    font-size: clamp(32px, 4.6vw, 68px);
    font-weight: 300;
    color: #ffffff;
    line-height: 1.04;
    letter-spacing: -0.025em;
  }

  .arrow-right {
    display: inline-block;
    margin-left: 0.18em;
    font-weight: 200;
    letter-spacing: 0;
  }

  .subtitle {
    margin-top: clamp(14px, 2.2%, 28px);
    font-size: clamp(14px, 1.5vw, 21px);
    font-weight: 300;
    color: rgba(255,255,255,0.80);
    line-height: 1.42;
  }

  .subtitle-dash { color: rgba(255,255,255,0.55); margin-right: 0.3em; }
  .subtitle-indent { padding-left: 1.4em; }

  .left-footer {
    margin-top: clamp(20px, 3%, 36px);
    display: flex;
    gap: clamp(12px, 3%, 36px);
    flex-wrap: wrap;
  }

  .left-footer span {
    font-size: clamp(9px, 0.85vw, 11.5px);
    color: rgba(255,255,255,0.40);
    letter-spacing: 0.04em;
    white-space: nowrap;
  }

  /* ── RIGHT PANEL (fan, centered) ───────────────── */
  .right { flex: 1; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }

  #fan-svg { width: 100%; height: 100%; display: block; max-width: calc((100vh - 114px) * 820 / 780); }

  /* fan + inner rotor rotation driven by JS requestAnimationFrame */

  /* ── LABELS PANEL (rechts vom Fan) ──────────────── */
  .fan-labels-panel {
    flex: 0 0 300px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 56px;
    padding: 0 64px 0 80px;
  }
  .fan-label-item { display: flex; flex-direction: column; gap: 13px; }
  .fan-label-line { width: 100%; height: 1px; background: rgba(255,255,255,0.20); }
  .fan-label-meta { display: flex; align-items: baseline; gap: 14px; }
  .fan-label-num {
    font-family: 'Helvetica Neue', Arial, sans-serif;
    font-size: 11px; color: rgba(255,255,255,0.45);
    letter-spacing: 0.05em; flex-shrink: 0;
  }
  .fan-label-text {
    font-family: 'Helvetica Neue', Arial, sans-serif;
    font-size: 13.5px; font-weight: 300;
    color: rgba(255,255,255,0.80); letter-spacing: 0.01em;
  }

  /* ── MOBILE / PORTRAIT ──────────────────────────── */
  @media (max-width: 640px) {
    #card-wrap {
      padding: 8px;
      align-items: stretch;
    }
    .card {
      width: 100%;
      height: calc(100vh - 90px - 16px);
      flex-direction: column;
    }
    .left {
      flex: 0 0 auto;
      border-right: none;
      border-bottom: 1px solid rgba(255,255,255,0.13);
      padding: 20px 24px 16px;
    }
    .right {
      flex: 1;
      min-height: 0;
    }
    .fan-labels-panel {
      flex: 0 0 auto;
      flex-direction: row; flex-wrap: wrap;
      gap: 20px 36px;
      padding: 20px 24px;
      border-top: 1px solid rgba(255,255,255,0.10);
    }
    h1 { font-size: clamp(28px, 8vw, 44px); }
    .subtitle { font-size: clamp(13px, 3.8vw, 18px); margin-top: 10px; }
    .left-footer { margin-top: 12px; }
  }
</style>
</head>
<body>

__SIDEBAR_HTML__

<div id="main">

  <div id="header">
    <div class="header-left">
      <div class="series-label">Palantir Explained Series</div>
      <div class="rfx-num">RFX<span> #08</span></div>
    </div>
    <div class="divider"></div>
    <div class="header-center">
      <div class="header-title">Fan Architecture → Rotating Geometry<br>through Parametric Projection</div>
      <div class="header-link">mtu-aero-engines.com</div>
    </div>
    <div class="header-right">
      <svg class="icon-circle" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
        <circle cx="14" cy="14" r="12" stroke="rgba(255,255,255,0.50)" stroke-width="1.4"/>
        <circle cx="14" cy="14" r="6.5" stroke="rgba(255,255,255,0.50)" stroke-width="1.0"/>
        <circle cx="14" cy="14" r="2" fill="rgba(255,255,255,0.50)"/>
      </svg>
      <div class="copyright">©2026</div>
    </div>
  </div>

<div id="card-wrap">
<div class="card">

  <div class="right">
    <svg id="fan-svg" viewBox="0 0 820 780" xmlns="http://www.w3.org/2000/svg">
      <rect width="820" height="780" fill="#2d2d2d"/>

      <defs>
        <marker id="arr" viewBox="0 0 10 10" refX="8" refY="5"
          markerWidth="5" markerHeight="5" orient="auto">
          <path d="M1 1L9 5L1 9" fill="none" stroke="rgba(255,255,255,0.55)"
            stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
        </marker>
        <marker id="arr-sm" viewBox="0 0 10 10" refX="8" refY="5"
          markerWidth="4" markerHeight="4" orient="auto">
          <path d="M1 1L9 5L1 9" fill="none" stroke="rgba(255,255,255,0.45)"
            stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
        </marker>
        <clipPath id="fan-clip">
          <circle cx="330" cy="415" r="261"/>
        </clipPath>
      </defs>

    </svg>
  </div><!-- /right -->

  <div class="fan-labels-panel">
    <div class="fan-label-item">
      <div class="fan-label-line"></div>
      <div class="fan-label-meta">
        <span class="fan-label-num">01</span>
        <span class="fan-label-text">Intake Flow Path</span>
      </div>
    </div>
    <div class="fan-label-item">
      <div class="fan-label-line"></div>
      <div class="fan-label-meta">
        <span class="fan-label-num">02</span>
        <span class="fan-label-text">Fan Stage Mechanics</span>
      </div>
    </div>
  </div><!-- /fan-labels-panel -->

</div><!-- /card -->
</div><!-- /card-wrap -->
</div><!-- /main -->

<script>
__SIDEBAR_JS__
(function(){
  const svg = document.getElementById('fan-svg');
  const ns  = 'http://www.w3.org/2000/svg';
  const CX  = 330, CY = 415;

  function el(tag, attrs, parent) {
    const e = document.createElementNS(ns, tag);
    for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
    (parent || svg).appendChild(e);
    return e;
  }

  function polar(r, deg) {
    const a = (deg - 90) * Math.PI / 180;
    return [CX + r * Math.cos(a), CY + r * Math.sin(a)];
  }

  function f(n) { return n.toFixed(2); }

  const R_CLIP        = 261;
  const R_WALL2       = 274;
  const R_WALL3       = 283;
  const R_OUT         = 296;
  const R_OUTER_FAINT = 304;

  const nacPath =
    `M ${f(CX)} ${f(CY - R_OUT)} A ${R_OUT} ${R_OUT} 0 1 1 ${f(CX - 0.001)} ${f(CY - R_OUT)} Z ` +
    `M ${f(CX)} ${f(CY - R_CLIP)} A ${R_CLIP} ${R_CLIP} 0 1 1 ${f(CX - 0.001)} ${f(CY - R_CLIP)} Z`;
  el('path', { d: nacPath, fill: 'rgba(255,255,255,0.04)', 'fill-rule': 'evenodd' });

  [
    [R_CLIP,        '1.0', 0.55],
    [R_WALL2,       '0.6', 0.30],
    [R_WALL3,       '1.3', 0.55],
    [R_OUT,         '2.4', 0.82],
    [R_OUTER_FAINT, '0.6', 0.20],
  ].forEach(([r, sw, op]) =>
    el('circle', { cx: CX, cy: CY, r,
      fill: 'none',
      stroke: `rgba(255,255,255,${op})`,
      'stroke-width': sw })
  );

  const particleG = el('g', { id: 'intake-particles' });
  const rotorG = el('g', { id: 'fan-rotor' });

  const N     = 20;
  const HUB_R = 88;
  const EXT_R = 340;

  function bladePath(i) {
    const b = (i / N) * 360;
    const [x0, y0] = polar(HUB_R,  b +  9);
    const [x1, y1] = polar(EXT_R,  b +  1);
    const [x2, y2] = polar(EXT_R,  b - 34);
    const [x3, y3] = polar(HUB_R,  b -  9);
    const [c1x, c1y] = polar(HUB_R + 65,  b +  8.5);
    const [c2x, c2y] = polar(EXT_R - 100, b +  1.5);
    const [c3x, c3y] = polar(EXT_R - 100, b - 29);
    const [c4x, c4y] = polar(HUB_R + 65,  b - 11);
    return [
      `M ${f(x0)} ${f(y0)}`,
      `C ${f(c1x)} ${f(c1y)} ${f(c2x)} ${f(c2y)} ${f(x1)} ${f(y1)}`,
      `L ${f(x2)} ${f(y2)}`,
      `C ${f(c3x)} ${f(c3y)} ${f(c4x)} ${f(c4y)} ${f(x3)} ${f(y3)}`,
      `A ${HUB_R} ${HUB_R} 0 0 1 ${f(x0)} ${f(y0)} Z`
    ].join(' ');
  }

  function bladeLeadingPath(i) {
    const b = (i / N) * 360;
    const [x0, y0] = polar(HUB_R,  b +  9);
    const [x1, y1] = polar(EXT_R,  b +  1);
    const [c1x, c1y] = polar(HUB_R + 65,  b +  8.5);
    const [c2x, c2y] = polar(EXT_R - 100, b +  1.5);
    return `M ${f(x0)} ${f(y0)} C ${f(c1x)} ${f(c1y)} ${f(c2x)} ${f(c2y)} ${f(x1)} ${f(y1)}`;
  }

  function bladeTrailingPath(i) {
    const b = (i / N) * 360;
    const [x0, y0] = polar(HUB_R,  b +  9);
    const [x1, y1] = polar(EXT_R,  b +  1);
    const [x2, y2] = polar(EXT_R,  b - 34);
    const [x3, y3] = polar(HUB_R,  b -  9);
    const [c3x, c3y] = polar(EXT_R - 100, b - 29);
    const [c4x, c4y] = polar(HUB_R + 65,  b - 11);
    return [
      `M ${f(x1)} ${f(y1)}`,
      `L ${f(x2)} ${f(y2)}`,
      `C ${f(c3x)} ${f(c3y)} ${f(c4x)} ${f(c4y)} ${f(x3)} ${f(y3)}`,
      `A ${HUB_R} ${HUB_R} 0 0 1 ${f(x0)} ${f(y0)}`
    ].join(' ');
  }

  const bladeFillG     = el('g', { 'clip-path': 'url(#fan-clip)' }, rotorG);
  const bladeLeadingG  = el('g', { 'clip-path': 'url(#fan-clip)' }, rotorG);
  const bladeTrailingG = el('g', { 'clip-path': 'url(#fan-clip)' }, rotorG);

  for (let i = 0; i < N; i++) {
    el('path', { d: bladePath(i), fill: '#2d2d2d', stroke: 'none' }, bladeFillG);
    el('path', {
      d: bladeLeadingPath(i),
      fill: 'none',
      stroke: 'rgba(255,255,255,0.22)',
      'stroke-width': '0.9',
      'stroke-linejoin': 'round',
      'stroke-linecap': 'butt'
    }, bladeLeadingG);
    el('path', {
      d: bladeTrailingPath(i),
      fill: 'none',
      stroke: 'rgba(255,255,255,0.80)',
      'stroke-width': '1.1',
      'stroke-linejoin': 'round',
      'stroke-linecap': 'butt'
    }, bladeTrailingG);
  }

  function hash(n) {
    const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
    return x - Math.floor(x);
  }
  let placed = 0;
  for (let i = 0; placed < 220 && i < 1400; i++) {
    const r   = 95 + hash(i * 3) * 160;
    const deg = hash(i * 3 + 1) * 360;
    const [x, y] = polar(r, deg);
    const dx = x - CX, dy = y - CY;
    if (dx * dx + dy * dy > R_CLIP * R_CLIP) continue;
    el('circle', { cx: f(x), cy: f(y), r: '1.3',
      fill: `rgba(255,255,255,${(0.15 + hash(i) * 0.30).toFixed(2)})` }, rotorG);
    placed++;
  }

  el('circle', { cx: CX, cy: CY, r: HUB_R, fill: '#2d2d2d' }, rotorG);

  [
    [88,  1.8, 0.80],
    [76,  0.8, 0.55],
    [62,  0.7, 0.45],
    [48,  0.7, 0.42],
    [36,  1.0, 0.65],
  ].forEach(([r, sw, op]) =>
    el('circle', { cx: CX, cy: CY, r,
      fill: 'none',
      stroke: `rgba(255,255,255,${op})`,
      'stroke-width': sw }, rotorG)
  );

  for (let i = 0; i < 12; i++) {
    const [ax, ay] = polar(40, i * 30);
    const [bx, by] = polar(75, i * 30);
    el('line', { x1: f(ax), y1: f(ay), x2: f(bx), y2: f(by),
      stroke: 'rgba(255,255,255,0.28)', 'stroke-width': '0.65' }, rotorG);
  }

  {
    let d = '';
    for (let a = 0; a <= 1080; a += 3) {
      const r = 3.5 + (33 / 1080) * a;
      const [x, y] = polar(r, a);
      d += (a === 0 ? 'M ' : 'L ') + f(x) + ' ' + f(y) + ' ';
    }
    el('path', { d, fill: 'none',
      stroke: 'rgba(255,255,255,0.65)',
      'stroke-width': '0.95',
      'stroke-linecap': 'round' }, rotorG);
  }

  el('circle', { cx: CX, cy: CY, r: '3.5', fill: 'rgba(255,255,255,0.88)' }, rotorG);

  // ── Gegenläufige HPC-Innenstufe ───────────────────────────────
  const innerRotorG = el('g', { id: 'fan-inner-rotor' });
  const N_I = 9, IH = 15, IE = 60;
  const iFillG = el('g', {}, innerRotorG);
  const iLeadG = el('g', {}, innerRotorG);

  function iBladePath(i) {
    const b = (i / N_I) * 360;
    const [x0,y0]=polar(IH,b+15), [x1,y1]=polar(IE,b+4);
    const [x2,y2]=polar(IE,b-19), [x3,y3]=polar(IH,b-15);
    const [c1x,c1y]=polar(IH+16,b+13), [c2x,c2y]=polar(IE-18,b+3);
    const [c3x,c3y]=polar(IE-18,b-15), [c4x,c4y]=polar(IH+16,b-13);
    return `M ${f(x0)} ${f(y0)} C ${f(c1x)} ${f(c1y)} ${f(c2x)} ${f(c2y)} ${f(x1)} ${f(y1)} L ${f(x2)} ${f(y2)} C ${f(c3x)} ${f(c3y)} ${f(c4x)} ${f(c4y)} ${f(x3)} ${f(y3)} A ${IH} ${IH} 0 0 1 ${f(x0)} ${f(y0)} Z`;
  }

  for (let i = 0; i < N_I; i++) {
    el('path', { d: iBladePath(i), fill: 'rgba(255,255,255,0.05)', stroke: 'none' }, iFillG);
    const b = (i/N_I)*360;
    const [x0,y0]=polar(IH,b+15), [x1,y1]=polar(IE,b+4);
    const [c1x,c1y]=polar(IH+16,b+13), [c2x,c2y]=polar(IE-18,b+3);
    el('path', {
      d: `M ${f(x0)} ${f(y0)} C ${f(c1x)} ${f(c1y)} ${f(c2x)} ${f(c2y)} ${f(x1)} ${f(y1)}`,
      fill: 'none', stroke: 'rgba(255,255,255,0.62)', 'stroke-width': '0.85'
    }, iLeadG);
  }
  el('circle', { cx: CX, cy: CY, r: IE, fill: 'none', stroke: 'rgba(255,255,255,0.22)', 'stroke-width': '0.7' }, innerRotorG);
  el('circle', { cx: CX, cy: CY, r: IH, fill: '#2d2d2d' }, innerRotorG);
  el('circle', { cx: CX, cy: CY, r: IH, fill: 'none', stroke: 'rgba(255,255,255,0.75)', 'stroke-width': '1.4' }, innerRotorG);
  el('circle', { cx: CX, cy: CY, r: '3.2', fill: 'rgba(255,255,255,0.90)' }, innerRotorG);

  // ── Intake-Strömungspartikel ──────────────────────────────────
  const MAX_P = 45, pool = [];

  function spawnP() {
    const lane = (Math.random() - 0.5) * 2;
    const c = document.createElementNS(ns, 'circle');
    c.setAttribute('r', (0.7 + Math.random() * 0.85).toFixed(1));
    particleG.appendChild(c);
    return { x: 10 + Math.random() * 45, y: CY + lane * 195,
             spd: 1.1 + Math.random() * 0.9, life: 1, el: c };
  }

  function tickP(nrm) {
    if (pool.length < MAX_P && Math.random() < 0.25 + nrm * 0.3) pool.push(spawnP());
    for (let i = pool.length - 1; i >= 0; i--) {
      const p = pool[i];
      const dx = p.x - CX, dy = p.y - CY;
      const d  = Math.sqrt(dx*dx + dy*dy);
      p.x += p.spd * Math.max(1, (420 - d) / 110) * (0.8 + nrm * 1.2);
      p.y += (CY - p.y) * 0.018;
      if (d < R_CLIP + 25) p.life -= 0.07 + nrm * 0.04;
      if (p.life <= 0 || p.x > CX + 10) { p.el.remove(); pool.splice(i, 1); continue; }
      const op = (p.life * (0.25 + Math.random() * 0.12)).toFixed(2);
      p.el.setAttribute('cx', p.x.toFixed(1));
      p.el.setAttribute('cy', p.y.toFixed(1));
      p.el.setAttribute('fill', `rgba(180,210,255,${op})`);
    }
  }

  // ── Maus → Drehzahl ──────────────────────────────────────────
  let mxs = -1, mys = -1;
  svg.addEventListener('mousemove', e => {
    const r = svg.getBoundingClientRect();
    mxs = (e.clientX - r.left) * (820 / r.width);
    mys = (e.clientY - r.top)  * (780 / r.height);
  });
  svg.addEventListener('mouseleave', () => { mxs = -1; });

  // ── RAF-Loop ─────────────────────────────────────────────────
  let ang = 0, iAng = 0, spd = 0.34, tSpd = 0.34;
  const BASE = 0.34, MAXSPD = 3.0;

  (function tick() {
    const d   = mxs < 0 ? 9999 : Math.sqrt((mxs-CX)**2 + (mys-CY)**2);
    tSpd = BASE + Math.max(0, 1 - d / 370) * (MAXSPD - BASE);
    spd += (tSpd - spd) * 0.04;

    ang  += spd;
    iAng -= spd * 1.45;
    rotorG.setAttribute('transform',      `rotate(${ang},${CX},${CY})`);
    innerRotorG.setAttribute('transform', `rotate(${iAng},${CX},${CY})`);

    tickP(spd / MAXSPD);
    requestAnimationFrame(tick);
  })();

})();
</script>
</body>
</html>"""

HTML = (HTML
    .replace('__SIDEBAR_CSS__', SIDEBAR_CSS)
    .replace('__SIDEBAR_JS__', SIDEBAR_JS)
    .replace('__SIDEBAR_HTML__', sidebar_html('fan'))
)


# ══════════════════════════════════════════════════════════════════
#  WAVE PAGE  (/wave)
# ══════════════════════════════════════════════════════════════════

WAVE_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RFX — Wave Flow v2</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  :root {
    --sb-bg: rgba(244,241,236,0.95);
    --sb-border: rgba(0,80,200,0.14);
    --sb-label: rgba(0,90,210,0.55);
    --sb-accent: #1a6fd4;
    --sb-hover: rgba(26,111,212,0.07);
    --sb-hover-border: rgba(26,111,212,0.40);
    --sb-active-bg: rgba(26,111,212,0.10);
    --sb-icon: rgba(0,90,210,0.60);
    --sb-num: rgba(0,90,210,0.40);
  }

  __SIDEBAR_CSS__

  body {
    background: #f0ede8;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    overflow: hidden;
    height: 100vh;
    width: 100vw;
    display: flex;
    flex-direction: row;
  }

  #main {
    flex: 1;
    overflow-y: scroll;
    scroll-snap-type: y mandatory;
    height: 100vh;
  }

  #wave-s1 {
    height: 100vh;
    position: relative;
    scroll-snap-align: start;
    flex-shrink: 0;
  }

  canvas {
    position: absolute;
    inset: 0;
    width: 100%; height: 100%;
  }

  #header {
    position: absolute;
    top: 0; left: 0; right: 0;
    padding: 22px 36px 18px;
    display: flex;
    align-items: flex-start;
    border-bottom: 1px solid rgba(0,80,200,0.18);
    z-index: 10;
  }

  .header-left { display:flex; flex-direction:column; gap:4px; min-width:200px; }

  .series-label {
    font-size: 10px; letter-spacing: 0.14em;
    color: rgba(0,90,210,0.70); text-transform: uppercase; font-weight: 400;
  }

  .rfx-num {
    font-size: 64px; font-weight: 700; color: #1a6fd4;
    line-height: 0.88; letter-spacing: -0.03em;
  }

  .rfx-num span {
    font-size: 22px; font-weight: 300; vertical-align: top;
    padding-top: 8px; display: inline-block; letter-spacing: 0.01em;
  }

  .divider {
    width: 1px; background: rgba(0,80,200,0.22);
    margin: 0 32px; align-self: stretch;
  }

  .header-center { display:flex; flex-direction:column; justify-content:center; gap:10px; padding:4px 0; }

  .header-title {
    font-size: 20px; font-weight: 400; color: #1a6fd4;
    line-height: 1.25; letter-spacing: -0.01em; max-width: 420px;
  }

  .header-link {
    display: flex; align-items: center; gap: 8px;
    font-size: 10.5px; letter-spacing: 0.10em;
    color: rgba(0,90,210,0.65); text-transform: uppercase;
  }
  .header-link::before { content: '→'; font-size: 12px; }

  .header-right {
    margin-left: auto; display: flex; flex-direction: column;
    align-items: flex-end; justify-content: space-between; padding: 4px 0; gap: 24px;
  }

  .icon-circle { width: 28px; height: 28px; }

  .copyright { font-size: 10px; letter-spacing: 0.08em; color: rgba(0,90,210,0.50); }

  /* ── Scroll hint on Section 1 ─────────────────────────────── */
  .ws2-scroll-hint {
    position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%);
    color: rgba(26,111,212,0.50); font-size: 10px; letter-spacing: 0.12em;
    text-transform: uppercase; display: flex; flex-direction: column;
    align-items: center; gap: 5px; cursor: pointer; z-index: 11;
    animation: ws2hint 2.4s ease-in-out infinite;
  }
  .ws2-scroll-hint::after { content: '↓'; font-size: 16px; }
  @keyframes ws2hint { 0%,100%{opacity:0.4} 50%{opacity:0.9} }

  /* ── Section 2 ────────────────────────────────────────────── */
  #wave-s2 {
    height: 100vh; scroll-snap-align: start; flex-shrink: 0;
    overflow-y: auto;
    background: linear-gradient(180deg, #f4f1ec 0%, #edeae3 60%, #f0ede8 100%);
    display: flex; flex-direction: column;
  }
  .ws2-container {
    flex: 1; padding: 28px 48px 32px; display: flex; flex-direction: column;
    gap: 20px; max-width: 1100px; margin: 0 auto; width: 100%;
  }
  .ws2-hdr {
    display: flex; flex-direction: column; gap: 3px;
    padding-bottom: 12px; border-bottom: 1px solid rgba(26,111,212,0.18);
  }
  .ws2-hdr-label {
    font-size: 10px; letter-spacing: 0.14em; color: rgba(0,90,210,0.60); text-transform: uppercase;
  }
  .ws2-hdr-title {
    font-size: 20px; font-weight: 400; color: #1a6fd4; letter-spacing: -0.01em;
  }

  /* Circles row */
  .ws2-circle-row { display: flex; align-items: center; }
  .ws2-circle {
    background: rgba(244,241,236,0.88); border: 1.5px solid rgba(26,111,212,0.30);
    backdrop-filter: blur(8px); border-radius: 14px; padding: 18px 20px;
    min-width: 190px; display: flex; flex-direction: column; gap: 10px;
    transition: border-color 0.25s, background 0.25s;
  }
  .ws2-circle.ws2-filled { border-color: rgba(26,111,212,0.60); background: rgba(26,111,212,0.06); }
  .ws2-cbadge { font-size: 18px; color: #1a6fd4; }
  .ws2-cname  { font-size: 10px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: rgba(0,90,210,0.65); }
  .ws2-sel {
    background: rgba(255,255,255,0.72); border: 1px solid rgba(26,111,212,0.28);
    border-radius: 7px; padding: 5px 9px; font-size: 12px; color: #1a3050;
    cursor: pointer; width: 100%; transition: border-color 0.2s;
  }
  .ws2-sel:disabled { opacity: 0.40; cursor: not-allowed; }
  .ws2-sel:focus { outline: none; border-color: #1a6fd4; }

  /* SVG connector */
  .ws2-connector { flex: 1; padding: 0 16px; display: flex; align-items: center; }
  .ws2-connector svg { width: 100%; overflow: visible; }

  /* Analyse — drei pulsierende Kreise */
  @keyframes ws2dot-pulse {
    0%, 100% { transform: scale(1);    opacity: 0.28; }
    50%       { transform: scale(1.7); opacity: 1;    }
  }
  .ws2-btn-row { display: flex; justify-content: center; }
  .ws2-dot-trigger {
    display: flex; align-items: center; gap: 11px;
    padding: 16px 22px; cursor: pointer; user-select: none;
    border-radius: 50px; transition: opacity 0.3s;
  }
  .ws2-dot-trigger.ws2-disabled {
    opacity: 0.2; cursor: not-allowed; pointer-events: none;
  }
  .ws2-dot-trigger .ws2-dot {
    width: 9px; height: 9px; border-radius: 50%;
    background: #1a6fd4;
    opacity: 0.28; transform: scale(1);
  }
  .ws2-dot-trigger:not(.ws2-disabled) .ws2-dot { opacity: 0.70; }
  .ws2-dot-trigger:not(.ws2-disabled):hover .ws2-dot { background: #3b8fe8; opacity: 1; }
  .ws2-dot-trigger.ws2-running .ws2-dot {
    animation: ws2dot-pulse 0.65s ease-in-out infinite;
    background: #3b8fe8;
  }
  .ws2-dot-trigger.ws2-running .ws2-dot:nth-child(1) { animation-delay: 0s; }
  .ws2-dot-trigger.ws2-running .ws2-dot:nth-child(2) { animation-delay: 0.25s; }
  .ws2-dot-trigger.ws2-running .ws2-dot:nth-child(3) { animation-delay: 0.5s; }

  /* Branch meta bar */
  .ws2-branch-meta {
    display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
    padding: 7px 14px; border-radius: 8px; margin-bottom: 6px;
    background: rgba(26,111,212,0.06); border: 1px solid rgba(26,111,212,0.16);
    font-size: 11px; color: rgba(0,60,140,0.70); letter-spacing: 0.03em;
    opacity: 0; animation: ws2metafade 0.4s ease forwards;
  }
  @keyframes ws2metafade { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:none; } }
  .ws2-meta-item { display: flex; align-items: center; gap: 6px; }
  .ws2-meta-lbl { font-weight: 700; color: rgba(0,60,140,0.50); text-transform: uppercase; letter-spacing: 0.09em; font-size: 9.5px; }
  .ws2-meta-val { font-weight: 600; color: #1a3d80; }
  .ws2-meta-sep { width: 1px; height: 14px; background: rgba(26,111,212,0.22); flex-shrink: 0; }

  /* Branches */
  .ws2-branches { display: flex; flex-direction: column; gap: 5px; min-height: 20px; }
  .ws2-branch-wrap { display: flex; flex-direction: column; }
  .ws2-branch-row {
    display: flex; align-items: center; gap: 0;
    opacity: 0; transform: translateX(-14px);
    transition: opacity 0.35s ease, transform 0.35s ease;
  }
  .ws2-branch-row.ws2-vis { opacity: 1; transform: translateX(0); }
  .ws2-branch-lbl {
    font-size: 12px; font-weight: 600; color: rgba(0,60,140,0.72);
    min-width: 130px; letter-spacing: 0.04em; white-space: nowrap;
  }
  .ws2-branch-line { width: 36px; height: 1px; background: rgba(26,111,212,0.35); flex-shrink: 0; }
  .ws2-branch-pills { display: flex; flex-wrap: wrap; gap: 6px; }
  .ws2-pill {
    border-radius: 20px; padding: 3px 11px; font-size: 11px; font-weight: 600;
    cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; letter-spacing: 0.03em;
    white-space: nowrap;
  }
  .ws2-pill.ok   { background: rgba(34,197,94,0.14); color: rgba(20,120,60,0.85); border: 1px solid rgba(34,197,94,0.38); }
  .ws2-pill.fail { background: rgba(239,68,68,0.11);  color: rgba(180,30,30,0.85); border: 1px solid rgba(239,68,68,0.30); }
  .ws2-pill.best { box-shadow: 0 0 0 2px #1a6fd4; }
  .ws2-pill.ws2-active { box-shadow: 0 0 0 2.5px #1a6fd4; transform: scale(1.07); }
  .ws2-pill:hover { transform: scale(1.06); }
  .ws2-pill.best.ws2-active { box-shadow: 0 0 0 3px #1a6fd4; }

  /* Inline detail panel */
  .ws2-detail {
    margin-left: 166px; /* label + line */
    background: rgba(26,111,212,0.055); border-left: 2px solid #1a6fd4;
    border-radius: 0 8px 8px 0; font-size: 11px; color: #1a3050;
    overflow: hidden; max-height: 0; opacity: 0;
    transition: max-height 0.35s ease, opacity 0.25s ease, padding 0.25s ease;
    padding: 0 14px;
  }
  .ws2-detail.ws2-open { max-height: 280px; opacity: 1; padding: 10px 14px; }
  .ws2-det-ts { font-size: 10px; color: rgba(0,90,210,0.55); margin-bottom: 7px; }
  .ws2-det-row {
    display: flex; justify-content: space-between; gap: 12px;
    padding: 2px 0; border-bottom: 1px solid rgba(26,111,212,0.09);
  }
  .ws2-det-row:last-child { border-bottom: none; }
  .ws2-det-row.viol { color: #c0392b; font-weight: 600; background: rgba(239,68,68,0.06); border-radius: 3px; padding: 2px 5px; }
  .ws2-det-key { color: rgba(0,60,140,0.65); }
  .ws2-det-val { font-weight: 600; text-align: right; }

  /* Circle 3 — Flow */
  .ws2-c3 {
    align-self: center; min-width: 240px; max-width: 340px; margin-top: 18px;
    opacity: 0; transform: translateY(10px);
    transition: opacity 0.40s ease, transform 0.40s ease;
    pointer-events: none; display: none;
  }
  .ws2-c3.ws2-vis { opacity: 1; transform: translateY(0); pointer-events: all; display: flex !important; }
  .ws2-ratings { display: flex; flex-wrap: wrap; gap: 5px; }
  .ws2-rchip {
    padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600;
    cursor: pointer; background: rgba(26,111,212,0.08); border: 1px solid rgba(26,111,212,0.28);
    color: rgba(0,60,140,0.72); transition: background 0.15s, border-color 0.15s;
  }
  .ws2-rchip.ws2-active { background: rgba(26,111,212,0.22); border-color: #1a6fd4; color: #1a3d80; }
  .ws2-run-btn {
    background: linear-gradient(135deg, #1a6fd4, #1558b0); color: #fff;
    border: none; border-radius: 8px; padding: 7px 18px; font-size: 12px;
    font-weight: 600; cursor: pointer; transition: opacity 0.2s; margin-top: 2px;
  }
  .ws2-run-btn:disabled { opacity: 0.32; cursor: not-allowed; }
  .ws2-run-btn:not(:disabled):hover { opacity: 0.84; }
  .ws2-run-res { font-size: 11px; display: none; margin-top: 4px; }
  .ws2-run-res.ok  { color: rgba(20,120,60,0.85); display: block; }
  .ws2-run-res.err { color: #c0392b; display: block; }

  /* Raw scan table card */
  .ws2-raw-scans-card { width: 100%; max-width: 100%; box-sizing: border-box; }
  .ws2-raw-tbl-wrap { max-height: 340px; overflow-y: auto; margin-top: 8px;
    border-radius: 8px; border: 1px solid rgba(26,111,212,0.16); }
  .ws2-raw-tbl { width: 100%; border-collapse: collapse; font-size: 11px; color: #1a3050; }
  .ws2-raw-tbl thead { position: sticky; top: 0; z-index: 1; }
  .ws2-raw-tbl th {
    background: rgba(26,111,212,0.08); padding: 5px 10px; text-align: left;
    font-size: 9.5px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase;
    color: rgba(0,60,140,0.55); border-bottom: 1px solid rgba(26,111,212,0.18);
    cursor: pointer; user-select: none; white-space: nowrap;
  }
  .ws2-raw-tbl th:hover { color: #1a6fd4; }
  .ws2-raw-tbl th.sort-asc::after  { content: ' ▲'; font-size: 8px; }
  .ws2-raw-tbl th.sort-desc::after { content: ' ▼'; font-size: 8px; }
  .ws2-raw-tbl td {
    padding: 4px 10px; border-bottom: 1px solid rgba(26,111,212,0.07);
    white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;
  }
  .ws2-raw-tbl tr:last-child td { border-bottom: none; }
  .ws2-raw-tbl tr:hover td { background: rgba(26,111,212,0.04); }
  .ws2-raw-tbl td:first-child { color: rgba(0,60,140,0.35); font-family: monospace; font-size: 10px; }
  .ws2-raw-count { font-size: 10px; color: rgba(0,60,140,0.50); margin-top: 2px; letter-spacing: 0.03em; }

  /* HB Order Card */
  .ws2-hb-card {
    background: rgba(244,241,236,0.88); border: 1.5px solid rgba(26,111,212,0.30);
    backdrop-filter: blur(8px); border-radius: 14px; padding: 18px 20px;
    display: flex; flex-direction: column; gap: 12px;
    opacity: 0; transform: translateY(6px);
    transition: opacity 0.38s ease, transform 0.38s ease, border-color 0.25s;
  }
  .ws2-hb-card.ws2-vis { opacity: 1; transform: none; }
  .ws2-hb-card.ws2-filled { border-color: rgba(26,111,212,0.60); background: rgba(26,111,212,0.06); }
  .ws2-hb-header { display: flex; align-items: center; gap: 8px; }
  .ws2-hb-meta { display: flex; flex-wrap: wrap; gap: 5px 0; align-items: center; }
  .ws2-hb-meta-item { display: flex; flex-direction: column; gap: 1px; padding: 0 14px; }
  .ws2-hb-meta-item:first-child { padding-left: 0; }
  .ws2-hb-meta-lbl {
    font-size: 9px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
    color: rgba(0,60,140,0.45);
  }
  .ws2-hb-meta-val { font-size: 12px; font-weight: 600; color: #1a3d80; }
  .ws2-hb-sep { width: 1px; height: 30px; background: rgba(26,111,212,0.20); flex-shrink: 0; align-self: center; }
  .ws2-hb-sec-lbl {
    font-size: 9px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
    color: rgba(0,60,140,0.45); margin-bottom: 4px;
  }
  .ws2-hb-procs { display: flex; flex-direction: column; gap: 4px; }
  .ws2-hb-proc-hdr {
    display: flex; align-items: center; gap: 8px;
    padding: 5px 10px; border-radius: 7px; cursor: pointer;
    background: rgba(26,111,212,0.05); border: 1px solid rgba(26,111,212,0.14);
    font-size: 11px; color: #1a3050; user-select: none;
    transition: background 0.15s;
  }
  .ws2-hb-proc-hdr:hover { background: rgba(26,111,212,0.10); }
  .ws2-hb-proc-arrow { font-size: 9px; color: rgba(0,60,140,0.40); transition: transform 0.2s; flex-shrink: 0; }
  .ws2-hb-proc-hdr.open .ws2-hb-proc-arrow { transform: rotate(90deg); }
  .ws2-hb-proc-name { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .ws2-hb-proc-type { font-size: 9.5px; color: rgba(0,60,140,0.42); white-space: nowrap; }
  .ws2-hb-steps {
    overflow: hidden; max-height: 0;
    transition: max-height 0.28s ease, padding 0.22s;
    padding: 0 0 0 20px;
  }
  .ws2-hb-steps.open { max-height: 800px; padding: 4px 0 4px 20px; }
  .ws2-hb-step {
    display: flex; align-items: baseline; gap: 8px;
    padding: 3px 6px; font-size: 10.5px; color: #1a3050;
    border-left: 1.5px solid rgba(26,111,212,0.18); margin-bottom: 2px;
  }
  .ws2-hb-step-type { font-size: 9px; color: rgba(0,60,140,0.42); white-space: nowrap; flex-shrink: 0; }
  .ws2-hb-step-title { flex: 1; }
  .ws2-hb-info { font-size: 11px; color: rgba(0,60,140,0.40); font-style: italic; }
  .ws2-hb-kv { display: grid; grid-template-columns: minmax(120px,auto) 1fr; gap: 0; }
  .ws2-hb-kv-row {
    display: contents;
  }
  .ws2-hb-kv-row > span {
    padding: 3px 6px; font-size: 10.5px;
    border-bottom: 1px solid rgba(26,111,212,0.07);
  }
  .ws2-hb-kv-row:last-child > span { border-bottom: none; }
  .ws2-hb-kv-key { color: rgba(0,60,140,0.52); font-weight: 600; }
  .ws2-hb-kv-val { color: #1a3050; word-break: break-word; }

</style>
</head>
<body>

__SIDEBAR_HTML__

<div id="main">
  <section id="wave-s1">
  <canvas id="c"></canvas>

  <div id="header">
    <div class="header-left">
      <div class="series-label">Vector Flow Series</div>
      <div class="rfx-num">RFX<span> #09</span></div>
    </div>
    <div class="divider"></div>
    <div class="header-center">
      <div class="header-title">Wave Streaming → Real-Time Flow<br>for Real-Time Decisions</div>
      <div class="header-link">vector-flow.io</div>
    </div>
    <div class="header-right">
      <svg class="icon-circle" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
        <circle cx="14" cy="14" r="12" stroke="#1a6fd4" stroke-width="1.4"/>
        <circle cx="14" cy="14" r="6.5" stroke="#1a6fd4" stroke-width="1.0"/>
        <circle cx="14" cy="14" r="2" fill="#1a6fd4"/>
      </svg>
      <div class="copyright">©2025</div>
    </div>
  </div>

    <div class="ws2-scroll-hint" id="ws2-scroll-hint">Scan Validation</div>
  </section>

  <!-- Section 2: Scan Validation -->
  <section id="wave-s2">
    <div class="ws2-container">
      <div class="ws2-hdr">
        <div class="ws2-hdr-label">Vector Flow Series</div>
        <div class="ws2-hdr-title">Scan Validation</div>
      </div>

      <div class="ws2-circle-row">
        <div class="ws2-circle" id="ws2-c1">
          <div class="ws2-cbadge">⬡</div>
          <div class="ws2-cname">TARDIS</div>
          <select class="ws2-sel" id="ws2-proj-sel">
            <option value="">Projekt wählen…</option>
          </select>
          <select class="ws2-sel" id="ws2-test-sel" disabled>
            <option value="">Test wählen…</option>
          </select>
        </div>

        <div class="ws2-connector">
          <svg id="ws2-conn-svg" height="30" preserveAspectRatio="none">
            <line id="ws2-conn-line" x1="0" y1="15" x2="100%" y2="15"
                  stroke="rgba(26,111,212,0.40)" stroke-width="1.5"
                  stroke-dasharray="200" stroke-dashoffset="200"/>
          </svg>
        </div>

        <div class="ws2-circle" id="ws2-c2">
          <div class="ws2-cbadge">◈</div>
          <div class="ws2-cname">Recipe</div>
          <select class="ws2-sel" id="ws2-recipe-sel">
            <option value="">Engine Type wählen…</option>
          </select>
        </div>
      </div>

      <div class="ws2-btn-row">
        <div class="ws2-dot-trigger ws2-disabled" id="ws2-analyse-btn">
          <span class="ws2-dot"></span>
          <span class="ws2-dot"></span>
          <span class="ws2-dot"></span>
        </div>
      </div>

      <div class="ws2-branches" id="ws2-branches"></div>

      <div class="ws2-hb-card" id="ws2-hb-card" style="display:none"></div>

      <div class="ws2-circle ws2-raw-scans-card" id="ws2-raw-scans" style="display:none">
        <div class="ws2-cbadge">≡</div>
        <div class="ws2-cname">Alle Scans</div>
        <div class="ws2-raw-count" id="ws2-raw-count"></div>
        <div class="ws2-raw-tbl-wrap">
          <table class="ws2-raw-tbl" id="ws2-raw-tbl">
            <thead><tr id="ws2-raw-thead"></tr></thead>
            <tbody id="ws2-raw-tbody"></tbody>
          </table>
        </div>
      </div>

      <div class="ws2-circle ws2-c3" id="ws2-c3">
        <div class="ws2-cbadge">▶</div>
        <div class="ws2-cname">Flow starten</div>
        <select class="ws2-sel" id="ws2-flow-sel">
          <option value="">Flow wählen…</option>
        </select>
        <div class="ws2-ratings" id="ws2-ratings"></div>
        <button class="ws2-run-btn" id="ws2-run-btn" disabled>▶ Ausführen</button>
        <div class="ws2-run-res" id="ws2-run-res"></div>
      </div>
    </div>
  </section>

</div>

<script>
__SIDEBAR_JS__
(function () {
  const canvas = document.getElementById('c');
  const ctx    = canvas.getContext('2d');
  const main   = document.getElementById('main');
  let W, H, t = 0;

  const s1 = document.getElementById('wave-s1');
  function resize() {
    W = canvas.width  = s1.clientWidth;
    H = canvas.height = s1.clientHeight;
  }
  window.addEventListener('resize', resize);
  document.getElementById('sidebar').addEventListener('transitionend', resize);
  resize();

  const RIBBONS = [
    {
      lines: 26, color: [26, 109, 212],
      shape: (nx, t) =>
        0.38 * Math.sin(nx * Math.PI * 1.8 + t * 0.22) +
        0.10 * Math.sin(nx * Math.PI * 4.2 + t * 0.40 + 1.1),
      spread: 0.14,
    },
    {
      lines: 22, color: [26, 109, 212],
      shape: (nx, t) =>
        -0.30 * Math.sin(nx * Math.PI * 2.4 + t * 0.18 + 0.8) +
         0.12 * Math.sin(nx * Math.PI * 5.0 + t * 0.50 + 2.4),
      spread: 0.11,
    },
    {
      lines: 28, color: [26, 109, 212],
      shape: (nx, t) =>
        0.22 * Math.sin(nx * Math.PI * 3.0 + t * 0.30 + 1.6) +
        0.18 * Math.sin(nx * Math.PI * 1.4 + t * 0.14 + 3.2),
      spread: 0.16,
    },
    {
      lines: 20, color: [26, 109, 212],
      shape: (nx, t) =>
        -0.42 * Math.sin(nx * Math.PI * 1.2 + t * 0.26 + 2.0) +
         0.08 * Math.sin(nx * Math.PI * 6.0 + t * 0.60 + 0.5),
      spread: 0.10,
    },
    {
      lines: 24, color: [26, 109, 212],
      shape: (nx, t) =>
        0.15 * Math.sin(nx * Math.PI * 4.0 + t * 0.35 + 4.5) +
        0.25 * Math.sin(nx * Math.PI * 2.0 + t * 0.20 + 1.8),
      spread: 0.13,
    },
  ];

  function env(nx) { return 0.5 - 0.5 * Math.cos(nx * 2 * Math.PI); }

  const STEPS = 480;

  function draw() {
    ctx.clearRect(0, 0, W, H);

    const bg = ctx.createLinearGradient(0, 0, 0, H);
    bg.addColorStop(0,   '#f4f1ec');
    bg.addColorStop(0.5, '#edeae3');
    bg.addColorStop(1,   '#f4f1ec');
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);

    const headerH = 102;
    const availH  = H - headerH;
    const midY    = headerH + availH * 0.5;
    const scaleY  = availH * 0.44;

    for (const ribbon of RIBBONS) {
      const { lines, color, shape, spread } = ribbon;
      const [r, g, b] = color;

      for (let j = 0; j < lines; j++) {
        const offset = (j / (lines - 1)) - 0.5;
        const centreProximity = 1 - Math.abs(offset) * 2;
        const op  = 0.08 + 0.60 * Math.pow(centreProximity, 1.2);
        const sw  = 0.35 + 0.60 * centreProximity;

        ctx.beginPath();
        ctx.strokeStyle = `rgba(${r},${g},${b},${op.toFixed(3)})`;
        ctx.lineWidth   = sw;
        ctx.lineJoin    = 'round';

        for (let s = 0; s <= STEPS; s++) {
          const nx   = s / STEPS;
          const e    = env(nx);
          const cy   = midY + shape(nx, t) * scaleY;
          const perp = offset * spread * e * scaleY * 1.8;
          const micro = offset * 0.03 * scaleY
            * Math.sin(nx * Math.PI * 8 + t * 0.9 + j * 0.4);
          const x = nx * W;
          const y = cy + perp + micro;
          if (s === 0) ctx.moveTo(x, y);
          else         ctx.lineTo(x, y);
        }
        ctx.stroke();
      }
    }

    t += 0.022;
    requestAnimationFrame(draw);
  }

  draw();
})();

// ── Wave Section 2 — Scan Validation ─────────────────────────────
(function() {
  let ws2 = {
    project: '', testId: '', testName: '', engineType: '',
    esn: '', wo: '',
    analysisResult: null, selectedScans: {},
    hbData: null,
    activePill: null,
    flows: [], selectedFlow: null, selectedFlowDef: null,
    certRatings: [], selectedRatings: [],
    initialized: false,
  };

  // ── Init ───────────────────────────────────────────────────────
  async function ws2Init() {
    await Promise.all([ws2LoadProjects(), ws2LoadRecipes(), ws2LoadFlows()]);
    ws2AnimateConnector();
    const params = new URLSearchParams(window.location.search);
    if (params.get('project') || params.get('esn') || params.get('engine')) {
      await ws2InjectFromParams(
        params.get('project') || '',
        params.get('esn')     || '',
        params.get('wo')      || '',
        params.get('engine')  || ''
      );
      window.history.replaceState({}, '', '/wave');
    }
  }

  async function ws2InjectFromParams(project, esn, wo, engine) {
    ws2.esn = esn || '';
    ws2.wo  = wo  || '';
    // 1. Projekt-Dropdown
    if (project) {
      const projSel = document.getElementById('ws2-proj-sel');
      projSel.value = project;
      ws2.project   = project;
      document.getElementById('ws2-c1').classList.add('ws2-filled');
    }

    // 2. Tests laden + passendes Test vorauswählen
    if (project) {
      const testSel = document.getElementById('ws2-test-sel');
      testSel.innerHTML = '<option value="">Lade\u2026</option>';
      testSel.disabled  = true;
      const tests = await fetch('/api/tardis/tests?project=' + encodeURIComponent(project) + '&pool=engine')
        .then(r => r.json()).catch(() => []);
      tests.sort((a, b) => Number(b.id) - Number(a.id));
      testSel.innerHTML = '<option value="">Test w\u00e4hlen\u2026</option>';
      tests.forEach(t => {
        const o = document.createElement('option');
        o.value = JSON.stringify({id: t.id, name: t.name});
        o.textContent = t.name;
        testSel.appendChild(o);
      });
      testSel.disabled = false;
      if (esn || wo) {
        const match = tests.find(t =>
          (!esn || t.name.includes(esn)) && (!wo || t.name.includes(wo))
        );
        if (match) {
          testSel.value  = JSON.stringify({id: match.id, name: match.name});
          ws2.testId     = match.id;
          ws2.testName   = match.name;
        }
      }
    }

    // 3. Recipe / Engine-Type vorauswählen
    if (engine) {
      const recipeSel = document.getElementById('ws2-recipe-sel');
      const match = Array.from(recipeSel.options)
        .find(o => o.value && (engine.startsWith(o.value) || o.value.startsWith(engine)));
      if (match) {
        recipeSel.value = match.value;
        ws2.engineType  = match.value;
        document.getElementById('ws2-c2').classList.add('ws2-filled');
      }
    }

    ws2UpdateAnalyseBtn();
    if (ws2.testId && ws2.engineType) ws2Analyse();
  }

  async function ws2LoadProjects() {
    const data = await fetch('/api/tardis/projects').then(r=>r.json()).catch(()=>[]);
    const sel = document.getElementById('ws2-proj-sel');
    sel.innerHTML = '<option value="">Projekt wählen\u2026</option>';
    if (!Array.isArray(data)) return;
    data.sort((a,b) => a.name.localeCompare(b.name));
    data.forEach(p => {
      const o = document.createElement('option');
      o.value = p.name; o.textContent = p.name; sel.appendChild(o);
    });
  }

  async function ws2LoadTests(project) {
    const sel = document.getElementById('ws2-test-sel');
    sel.innerHTML = '<option value="">Lade\u2026</option>';
    sel.disabled = true;
    const data = await fetch('/api/tardis/tests?project=' + encodeURIComponent(project) + '&pool=engine').then(r=>r.json()).catch(()=>[]);
    sel.innerHTML = '<option value="">Test w\u00e4hlen\u2026</option>';
    if (!Array.isArray(data)) { sel.disabled = false; return; }
    data.sort((a,b) => Number(b.id) - Number(a.id));
    data.forEach(t => {
      const o = document.createElement('option');
      o.value = JSON.stringify({id: t.id, name: t.name});
      o.textContent = t.name; sel.appendChild(o);
    });
    sel.disabled = false;
  }

  async function ws2LoadRecipes() {
    const data = await fetch('/api/recipes').then(r=>r.json()).catch(()=>[]);
    const sel = document.getElementById('ws2-recipe-sel');
    sel.innerHTML = '<option value="">Engine Type w\u00e4hlen\u2026</option>';
    if (!Array.isArray(data)) return;
    data.sort((a,b) => (a.engine_type||'').localeCompare(b.engine_type||''));
    data.forEach(r => {
      const o = document.createElement('option');
      o.value = r.engine_type; o.textContent = r.engine_type; sel.appendChild(o);
    });
  }

  async function ws2LoadFlows() {
    const data = await fetch('/api/flow/list').then(r=>r.json()).catch(()=>[]);
    ws2.flows = Array.isArray(data) ? data : [];
    const sel = document.getElementById('ws2-flow-sel');
    sel.innerHTML = '<option value="">Flow w\u00e4hlen\u2026</option>';
    ws2.flows.forEach(f => {
      const o = document.createElement('option');
      o.value = f.file; o.textContent = f.name || f.file; sel.appendChild(o);
    });
  }

  // ── Event wiring ───────────────────────────────────────────────
  document.getElementById('ws2-proj-sel').addEventListener('change', async function() {
    ws2.project = this.value;
    ws2.testId = ''; ws2.testName = '';
    document.getElementById('ws2-c1').classList.toggle('ws2-filled', !!ws2.project);
    ws2UpdateAnalyseBtn();
    if (ws2.project) {
      await ws2LoadTests(ws2.project);
    } else {
      const s = document.getElementById('ws2-test-sel');
      s.innerHTML = '<option value="">Test w\u00e4hlen\u2026</option>'; s.disabled = true;
    }
  });

  document.getElementById('ws2-test-sel').addEventListener('change', function() {
    if (!this.value) { ws2.testId = ''; ws2.testName = ''; ws2.esn = ''; ws2.wo = ''; }
    else {
      try {
        const p = JSON.parse(this.value);
        ws2.testId   = p.id;
        ws2.testName = p.name;
        const esnM = p.name.match(/\b(GA\d{4,8}|\d{6})\b/);
        const woM  = p.name.match(/\b([A-Z]\d{4,6})\b/);
        ws2.esn = esnM ? esnM[1] : '';
        ws2.wo  = woM  ? woM[1]  : '';
      } catch(e) {}
    }
    ws2UpdateAnalyseBtn();
  });

  document.getElementById('ws2-recipe-sel').addEventListener('change', function() {
    ws2.engineType = this.value;
    document.getElementById('ws2-c2').classList.toggle('ws2-filled', !!ws2.engineType);
    ws2UpdateAnalyseBtn();
  });

  document.getElementById('ws2-analyse-btn').addEventListener('click', ws2Analyse);

  document.getElementById('ws2-flow-sel').addEventListener('change', async function() {
    ws2.selectedFlow = this.value || null;
    ws2.selectedFlowDef = null;
    ws2.certRatings = []; ws2.selectedRatings = [];
    document.getElementById('ws2-ratings').innerHTML = '';
    if (!ws2.selectedFlow) { ws2UpdateRunBtn(); return; }
    const data = await fetch('/api/flow/load/' + encodeURIComponent(ws2.selectedFlow)).then(r=>r.json()).catch(()=>null);
    if (data && data.nodes) {
      ws2.selectedFlowDef = data;
      const hasCert = data.nodes.some(n => n.typeId === 'cert_build');
      if (hasCert && ws2.engineType) await ws2RenderRatingsChips(ws2.engineType);
    }
    ws2UpdateRunBtn();
  });

  document.getElementById('ws2-run-btn').addEventListener('click', ws2RunFlow);

  // ── Helpers ────────────────────────────────────────────────────
  function ws2UpdateAnalyseBtn() {
    document.getElementById('ws2-analyse-btn').classList.toggle('ws2-disabled', !(ws2.testId && ws2.engineType));
  }

  function ws2UpdateRunBtn() {
    const btn = document.getElementById('ws2-run-btn');
    if (!ws2.selectedFlow) { btn.disabled = true; return; }
    if (!ws2.selectedFlowDef) { btn.disabled = false; return; }
    const hasCert = ws2.selectedFlowDef.nodes.some(n => n.typeId === 'cert_build');
    btn.disabled = hasCert && ws2.selectedRatings.length === 0;
  }

  // ── SVG connector animation ─────────────────────────────────────
  function ws2AnimateConnector() {
    const svg  = document.getElementById('ws2-conn-svg');
    const line = document.getElementById('ws2-conn-line');
    if (!svg || !line) return;
    const len = svg.clientWidth || 200;
    line.setAttribute('stroke-dasharray', len);
    line.setAttribute('stroke-dashoffset', len);
    let start = null;
    function step(ts) {
      if (!start) start = ts;
      const prog  = Math.min((ts - start) / 900, 1);
      const eased = 1 - Math.pow(1 - prog, 3);
      line.setAttribute('stroke-dashoffset', len * (1 - eased));
      if (prog < 1) requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
  }

  // ── Analyse ────────────────────────────────────────────────────
  async function ws2Analyse() {
    const btn = document.getElementById('ws2-analyse-btn');
    btn.classList.add('ws2-running', 'ws2-disabled');
    document.getElementById('ws2-branches').innerHTML = '';
    const c3 = document.getElementById('ws2-c3');
    c3.classList.remove('ws2-vis');
    setTimeout(() => { if (!c3.classList.contains('ws2-vis')) c3.style.display = 'none'; }, 420);
    document.getElementById('ws2-run-res').className = 'ws2-run-res';
    document.getElementById('ws2-raw-scans').style.display = 'none';
    const hbCard = document.getElementById('ws2-hb-card');
    hbCard.classList.remove('ws2-vis');
    setTimeout(() => { if (!hbCard.classList.contains('ws2-vis')) hbCard.style.display = 'none'; }, 400);

    try {
      const bscUrl = '/api/flow/batch_scan_check?test_id=' + encodeURIComponent(ws2.testId) + '&engine_type=' + encodeURIComponent(ws2.engineType);
      const rawUrl = '/api/scantable?test_id=' + encodeURIComponent(ws2.testId);
      const hbPromise = (ws2.esn || ws2.wo)
        ? fetch('/api/hb/find_order?esn=' + encodeURIComponent(ws2.esn) + '&wo=' + encodeURIComponent(ws2.wo))
            .then(r => r.json())
            .then(o => o.order_id
              ? fetch('/api/hb/order_detail?order_id=' + encodeURIComponent(o.order_id)).then(r => r.json())
              : null)
            .catch(() => null)
        : Promise.resolve(null);
      const [result, rawData, hbData] = await Promise.all([
        fetch(bscUrl).then(r=>r.json()),
        fetch(rawUrl).then(r=>r.json()),
        hbPromise
      ]);
      if (result.error) throw new Error(result.error);
      ws2.analysisResult = result;
      ws2.selectedScans = {};
      ws2.hbData = hbData;
      ws2RenderHbCard(hbData);
      ws2RenderBranches(result);
      ws2RenderRawTable(rawData);
      setTimeout(() => {
        c3.style.display = 'flex';
        requestAnimationFrame(() => c3.classList.add('ws2-vis'));
      }, 400);
    } catch(e) {
      document.getElementById('ws2-branches').innerHTML =
        '<div style="color:#c0392b;font-size:12px;padding:6px 0">Fehler: ' + e.message + '</div>';
    } finally {
      btn.classList.remove('ws2-running');
      btn.classList.toggle('ws2-disabled', !(ws2.testId && ws2.engineType));
    }
  }

  // ── Raw scan table ─────────────────────────────────────────────
  let ws2RawData = null, ws2RawSortCol = null, ws2RawSortAsc = true;

  function ws2RenderRawTable(data, sortCol, sortAsc) {
    ws2RawData  = data;
    ws2RawSortCol = sortCol !== undefined ? sortCol : ws2RawSortCol;
    ws2RawSortAsc = sortAsc !== undefined ? sortAsc : ws2RawSortAsc;

    const card = document.getElementById('ws2-raw-scans');
    if (!data || data.error || !Array.isArray(data.columns)) {
      card.style.display = 'none'; return;
    }
    card.style.display = 'flex';

    document.getElementById('ws2-raw-count').textContent =
      data.rows.length + ' Zeilen' + (data.total_rows > data.rows.length ? ' (von ' + data.total_rows + ')' : '');

    const cols = data.columns;
    let rows = data.rows.map((r, i) => ({ idx: i + 1, values: r }));

    if (ws2RawSortCol !== null) {
      const ci = cols.indexOf(ws2RawSortCol);
      if (ci >= 0) {
        rows.sort((a, b) => {
          let va = a.values[ci], vb = b.values[ci];
          const na = parseFloat(va), nb = parseFloat(vb);
          if (!isNaN(na) && !isNaN(nb)) { va = na; vb = nb; }
          if (va < vb) return ws2RawSortAsc ? -1 : 1;
          if (va > vb) return ws2RawSortAsc ?  1 : -1;
          return 0;
        });
      }
    }

    const thead = document.getElementById('ws2-raw-thead');
    thead.innerHTML = '<th>#</th>' + cols.map(c => {
      const cls = c === ws2RawSortCol ? (ws2RawSortAsc ? 'sort-asc' : 'sort-desc') : '';
      return `<th class="${cls}" onclick="ws2SortRaw('${c}')">${c}</th>`;
    }).join('');

    document.getElementById('ws2-raw-tbody').innerHTML = rows.map(r =>
      '<tr><td>' + r.idx + '</td>' + r.values.map(v => `<td title="${v}">${v}</td>`).join('') + '</tr>'
    ).join('');
  }

  window.ws2SortRaw = function(col) {
    if (!ws2RawData) return;
    const sameCol = ws2RawSortCol === col;
    ws2RenderRawTable(ws2RawData, col, sameCol ? !ws2RawSortAsc : true);
  };

  // ── HB Order Card ──────────────────────────────────────────────
  function ws2RenderHbCard(data) {
    const card = document.getElementById('ws2-hb-card');
    if (!data || data.error) { card.style.display = 'none'; return; }

    card.innerHTML = '';
    card.style.display = 'flex';
    card.classList.add('ws2-filled');
    setTimeout(() => card.classList.add('ws2-vis'), 20);

    // Header
    const hdr = document.createElement('div');
    hdr.className = 'ws2-hb-header';
    hdr.innerHTML = '<span class="ws2-cbadge">⬡</span><span class="ws2-cname">HyperBoost Order</span>';
    card.appendChild(hdr);

    // Metadata strip
    const metaFields = [
      ['ESN',      data.esn],
      ['WBS',      data.buildnumber],
      ['Engine',   data.engine_type],
      ['Testcell', data.testcell],
      ['Type',     data.test_type],
    ].filter(([, v]) => v && v !== 'None' && v !== 'undefined');

    if (metaFields.length) {
      const meta = document.createElement('div');
      meta.className = 'ws2-hb-meta';
      metaFields.forEach(([lbl, val], i) => {
        if (i > 0) {
          const sep = document.createElement('div');
          sep.className = 'ws2-hb-sep';
          meta.appendChild(sep);
        }
        const item = document.createElement('div');
        item.className = 'ws2-hb-meta-item';
        item.innerHTML = '<span class="ws2-hb-meta-lbl">' + lbl + '</span>'
                       + '<span class="ws2-hb-meta-val">' + val + '</span>';
        meta.appendChild(item);
      });
      card.appendChild(meta);
    }

    // Weitere Details (order_name + test_info_params)
    const SKIP_META = new Set(['esn','buildnumber','engine_type','testcell','test_type',
                               'order_id','procedures','test_info_params']);
    const detailRows = [];
    if (data.order_name && data.order_name !== 'None')
      detailRows.push(['Order-Name', data.order_name]);
    Object.entries(data.test_info_params || {}).forEach(([k, v]) => {
      if (v && v !== 'None' && v !== 'nan') detailRows.push([k, v]);
    });
    // Extra order-level fields not already in meta strip
    Object.entries(data).forEach(([k, v]) => {
      if (SKIP_META.has(k) || k === 'order_name') return;
      if (typeof v === 'string' && v && v !== 'None' && v !== 'nan')
        detailRows.push([k, v]);
    });

    if (detailRows.length) {
      const detHdr = document.createElement('div');
      detHdr.className = 'ws2-hb-proc-hdr';
      detHdr.innerHTML = '<span class="ws2-hb-proc-arrow">▶</span>'
                       + '<span class="ws2-hb-proc-name">Weitere Details</span>'
                       + '<span class="ws2-hb-proc-type">' + detailRows.length + ' Felder</span>';

      const detBody = document.createElement('div');
      detBody.className = 'ws2-hb-steps';
      const grid = document.createElement('div');
      grid.className = 'ws2-hb-kv';
      detailRows.forEach(([k, v]) => {
        const row = document.createElement('div');
        row.className = 'ws2-hb-kv-row';
        row.innerHTML = '<span class="ws2-hb-kv-key">' + k + '</span>'
                      + '<span class="ws2-hb-kv-val">' + v + '</span>';
        grid.appendChild(row);
      });
      detBody.appendChild(grid);

      detHdr.addEventListener('click', () => {
        const isOpen = detHdr.classList.toggle('open');
        detBody.classList.toggle('open', isOpen);
      });

      const detWrap = document.createElement('div');
      detWrap.appendChild(detHdr);
      detWrap.appendChild(detBody);
      card.appendChild(detWrap);
    }

    // Procedures & Steps
    const procs = data.procedures || [];
    if (procs.length) {
      const secLbl = document.createElement('div');
      secLbl.className = 'ws2-hb-sec-lbl';
      secLbl.textContent = 'Procedures & Steps';
      card.appendChild(secLbl);

      const procsDiv = document.createElement('div');
      procsDiv.className = 'ws2-hb-procs';
      procs.forEach(proc => {
        const wrap = document.createElement('div');

        const procHdr = document.createElement('div');
        procHdr.className = 'ws2-hb-proc-hdr';
        const stepCount = (proc.steps || []).length;
        procHdr.innerHTML = '<span class="ws2-hb-proc-arrow">▶</span>'
                          + '<span class="ws2-hb-proc-name">' + (proc.title || proc.name) + '</span>'
                          + '<span class="ws2-hb-proc-type">' + (proc.type || '') + (stepCount ? ' · ' + stepCount + ' Steps' : '') + '</span>';

        const stepsDiv = document.createElement('div');
        stepsDiv.className = 'ws2-hb-steps';
        (proc.steps || []).forEach(s => {
          const row = document.createElement('div');
          row.className = 'ws2-hb-step';
          row.innerHTML = '<span class="ws2-hb-step-type">' + (s.type || '') + '</span>'
                        + '<span class="ws2-hb-step-title">' + (s.title || '') + '</span>';
          stepsDiv.appendChild(row);
        });

        procHdr.addEventListener('click', () => {
          const isOpen = procHdr.classList.toggle('open');
          stepsDiv.classList.toggle('open', isOpen);
        });

        wrap.appendChild(procHdr);
        wrap.appendChild(stepsDiv);
        procsDiv.appendChild(wrap);
      });
      card.appendChild(procsDiv);
    } else {
      const info = document.createElement('div');
      info.className = 'ws2-hb-info';
      info.textContent = 'Keine Procedures gefunden.';
      card.appendChild(info);
    }
  }

  // ── Branch rendering ───────────────────────────────────────────
  function ws2RenderBranches(result) {
    const container = document.getElementById('ws2-branches');
    container.innerHTML = '';
    const codes = Object.keys(result).filter(k => k !== '_meta').sort();
    if (codes.length === 0) {
      container.innerHTML = '<div style="font-size:12px;color:rgba(0,60,140,0.50);padding:4px 0">Keine Codes gefunden.</div>';
      return;
    }

    // ── Meta bar: collect all unique scan numbers across all codes ──
    const allScans = {};  // nr → timestamp (first found)
    codes.forEach(code => {
      Object.entries(result[code]).forEach(([nr, info]) => {
        if (!(nr in allScans)) allScans[nr] = info._timestamp || '';
      });
    });
    const allNrs   = Object.keys(allScans).sort((a,b) => parseFloat(a) - parseFloat(b));
    const totalCnt = allNrs.length;
    const firstNr  = allNrs[0];
    const lastNr   = allNrs[allNrs.length - 1];
    const firstTs  = firstNr ? (allScans[firstNr] || '\u2014') : '\u2014';
    const lastTs   = lastNr && lastNr !== firstNr ? (allScans[lastNr] || '\u2014') : null;

    const meta = document.createElement('div');
    meta.className = 'ws2-branch-meta';
    let metaHtml = '<div class="ws2-meta-item"><span class="ws2-meta-lbl">Scans gesamt</span><span class="ws2-meta-val">' + totalCnt + '</span></div>';
    if (firstNr) {
      metaHtml += '<div class="ws2-meta-sep"></div>'
        + '<div class="ws2-meta-item"><span class="ws2-meta-lbl">Scan ' + firstNr + ' (erster)</span><span class="ws2-meta-val">' + firstTs + '</span></div>';
    }
    if (lastTs) {
      metaHtml += '<div class="ws2-meta-sep"></div>'
        + '<div class="ws2-meta-item"><span class="ws2-meta-lbl">Scan ' + lastNr + ' (letzter)</span><span class="ws2-meta-val">' + lastTs + '</span></div>';
    }
    meta.innerHTML = metaHtml;
    container.appendChild(meta);

    codes.forEach((code, idx) => {
      const scans = result[code];
      const wrap  = document.createElement('div');
      wrap.className = 'ws2-branch-wrap';

      const row = document.createElement('div');
      row.className = 'ws2-branch-row';

      const lbl = document.createElement('div');
      lbl.className = 'ws2-branch-lbl';
      lbl.textContent = code;
      row.appendChild(lbl);

      const line = document.createElement('div');
      line.className = 'ws2-branch-line';
      row.appendChild(line);

      const pillsDiv = document.createElement('div');
      pillsDiv.className = 'ws2-branch-pills';

      const scanNrs = Object.keys(scans).sort((a,b) => parseFloat(a) - parseFloat(b));
      const okScans = scanNrs.filter(nr => scans[nr].__ok);
      const bestScan = okScans.length > 0
        ? okScans.reduce((a,b) => parseFloat(a) > parseFloat(b) ? a : b)
        : (scanNrs.length > 0 ? scanNrs[scanNrs.length - 1] : null);

      if (bestScan != null && !(code in ws2.selectedScans)) {
        ws2.selectedScans[code] = bestScan;
      }

      scanNrs.forEach(nr => {
        const info   = scans[nr];
        const ok     = info.__ok;
        const isBest = nr === bestScan;
        const isSel  = ws2.selectedScans[code] === nr;
        const pill   = document.createElement('button');
        pill.className = 'ws2-pill ' + (ok ? 'ok' : 'fail') + (isBest ? ' best' : '') + (isSel ? ' ws2-active' : '');
        pill.textContent = (ok ? '\u25cf ' : '\u25cb ') + 'S' + nr + (ok ? ' \u2713' : ' \u2717');
        pill.dataset.code = code;
        pill.dataset.scan = nr;
        pill.addEventListener('click', () => ws2PillClick(code, nr, pill, info));
        pillsDiv.appendChild(pill);
      });

      row.appendChild(pillsDiv);
      wrap.appendChild(row);

      const detail = document.createElement('div');
      detail.className = 'ws2-detail';
      detail.id = 'ws2-det-' + code.replace(/[^a-zA-Z0-9_-]/g, '_');
      wrap.appendChild(detail);

      container.appendChild(wrap);
      setTimeout(() => row.classList.add('ws2-vis'), 60 + idx * 75);
    });
  }

  // ── Pill click ─────────────────────────────────────────────────
  function ws2PillClick(code, nr, pillEl, info) {
    const safeId = 'ws2-det-' + code.replace(/[^a-zA-Z0-9_-]/g, '_');
    const detEl  = document.getElementById(safeId);
    const allPills = document.querySelectorAll('.ws2-pill[data-code="' + code + '"]');
    const alreadyOpen = detEl && detEl.classList.contains('ws2-open')
      && ws2.activePill && ws2.activePill.code === code && ws2.activePill.scan === nr;

    allPills.forEach(p => p.classList.remove('ws2-active'));
    if (detEl) detEl.classList.remove('ws2-open');
    if (alreadyOpen) { ws2.activePill = null; return; }

    ws2.selectedScans[code] = nr;
    pillEl.classList.add('ws2-active');
    ws2.activePill = {code, scan: nr};

    if (!detEl) return;
    const ts         = info._timestamp || '';
    const violations = info.__violations || [];
    const paramKeys  = Object.keys(info).filter(k => !k.startsWith('__') && k !== '_timestamp' && k !== '_ScanNr');

    let html = '<div class="ws2-det-ts">Scan ' + nr + (ts ? ' \u2014 ' + ts : '') + '</div>';
    if (paramKeys.length === 0) {
      html += '<div style="color:rgba(0,60,140,0.50);font-size:10.5px">Keine Kanalwerte verf\u00fcgbar.</div>';
    } else {
      paramKeys.forEach(k => {
        const val  = info[k];
        const viol = violations.find(v => v.startsWith(k));
        html += '<div class="ws2-det-row' + (viol ? ' viol' : '') + '">'
          + '<span class="ws2-det-key">' + k + '</span>'
          + '<span class="ws2-det-val">' + val + (viol ? ' \u26a0 ' + viol : '') + '</span>'
          + '</div>';
      });
    }
    detEl.innerHTML = html;
    detEl.classList.add('ws2-open');
  }

  // ── Ratings chips ──────────────────────────────────────────────
  async function ws2RenderRatingsChips(engineType) {
    const recipes = await fetch('/api/recipes').then(r=>r.json()).catch(()=>[]);
    const recipe  = recipes.find(r => r.engine_type === engineType);
    if (!recipe) return;
    ws2.certRatings     = recipe.ratings || [];
    ws2.selectedRatings = [];
    const container = document.getElementById('ws2-ratings');
    container.innerHTML = '<div style="font-size:10px;letter-spacing:0.08em;color:rgba(0,90,210,0.60);margin-bottom:4px;text-transform:uppercase">Ratings</div>';
    ws2.certRatings.forEach(rt => {
      const name = rt.name || rt;
      const btn  = document.createElement('button');
      btn.className = 'ws2-rchip';
      btn.textContent = name;
      btn.addEventListener('click', function() {
        this.classList.toggle('ws2-active');
        if (this.classList.contains('ws2-active')) {
          if (!ws2.selectedRatings.includes(name)) ws2.selectedRatings.push(name);
        } else {
          ws2.selectedRatings = ws2.selectedRatings.filter(r => r !== name);
        }
        ws2UpdateRunBtn();
      });
      container.appendChild(btn);
    });
    ws2UpdateRunBtn();
  }

  // ── Flow run ────────────────────────────────────────────────────
  async function ws2RunFlow() {
    if (!ws2.selectedFlowDef) return;
    const btn   = document.getElementById('ws2-run-btn');
    const resEl = document.getElementById('ws2-run-res');
    btn.disabled = true;
    btn.textContent = '\u27f3 \u2026';
    resEl.className = 'ws2-run-res';

    try {
      const nodes = JSON.parse(JSON.stringify(ws2.selectedFlowDef.nodes || []));
      const conns = JSON.parse(JSON.stringify(ws2.selectedFlowDef.connections || []));

      for (const nd of nodes) {
        if (nd.typeId === 'tardis_query') {
          nd.config.th_test_id = ws2.testId;
          nd.config.th_project = ws2.project;
          nd.config.th_pool    = 'engine';
        }
        if (nd.typeId === 'cert_build') {
          nd.config.selected_scans   = Object.assign({}, ws2.selectedScans);
          nd.config.selected_ratings = ws2.selectedRatings.slice();
          nd.config._last_test_id    = ws2.testId;
        }
        if (nd.typeId === 'hb_read') {
          const esnM = ws2.testName.match(/\b(GA\d{4,8}|\d{6})\b/);
          const woM  = ws2.testName.match(/\b([A-Z]\d{4,6})\b/);
          const esn  = esnM ? esnM[1] : '';
          const wo   = woM  ? woM[1]  : '';
          if (esn || wo) {
            const hb = await fetch('/api/hb/find_order?esn=' + encodeURIComponent(esn) + '&wo=' + encodeURIComponent(wo)).then(r=>r.json()).catch(()=>null);
            if (hb && !hb.error) nd.config.orderId = hb.order_id;
          }
        }
      }

      const resp = await fetch('/api/flow/run', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({nodes, connections: conns})
      }).then(r=>r.json());

      const allOk = Array.isArray(resp) ? resp.every(r => r.ok !== false) : (resp.ok !== false);
      resEl.className = 'ws2-run-res ' + (allOk ? 'ok' : 'err');
      resEl.textContent = allOk ? '\u2713 Flow abgeschlossen' : '\u26a0 Flow mit Fehlern';
    } catch(e) {
      resEl.className = 'ws2-run-res err';
      resEl.textContent = 'Fehler: ' + e.message;
    } finally {
      btn.textContent = '\u25b6 Ausf\u00fchren';
      ws2UpdateRunBtn();
    }
  }

  // ── Bootstrap ──────────────────────────────────────────────────
  const s2   = document.getElementById('wave-s2');
  const hint = document.getElementById('ws2-scroll-hint');
  if (hint) hint.addEventListener('click', () => s2.scrollIntoView({behavior:'smooth'}));

  const obs = new IntersectionObserver(function(entries) {
    if (entries[0].isIntersecting && !ws2.initialized) {
      ws2.initialized = true;
      ws2Init();
    }
  }, { threshold: 0.1 });
  obs.observe(s2);

  // Bei URL-Inject sofort zu #wave-s2 scrollen → triggert den Observer
  if (new URLSearchParams(window.location.search).get('project') ||
      new URLSearchParams(window.location.search).get('esn') ||
      new URLSearchParams(window.location.search).get('engine')) {
    requestAnimationFrame(() => {
      document.getElementById('main').scrollTo({top: s2.offsetTop, behavior: 'smooth'});
    });
  }
})();
</script>
</body>
</html>"""

WAVE_HTML = (WAVE_HTML
    .replace('__SIDEBAR_CSS__', SIDEBAR_CSS)
    .replace('__SIDEBAR_JS__', SIDEBAR_JS)
    .replace('__SIDEBAR_HTML__', sidebar_html('wave'))
)


# ══════════════════════════════════════════════════════════════════
#  TORUS PAGE  (/torus)
# ══════════════════════════════════════════════════════════════════

TORUS_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RFX — Torus</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  :root {
    --sb-bg: #1a3d28;
    --sb-border: rgba(255,255,255,0.10);
    --sb-label: rgba(255,255,255,0.45);
    --sb-accent: rgba(255,255,255,0.85);
    --sb-hover: rgba(255,255,255,0.05);
    --sb-hover-border: rgba(255,255,255,0.30);
    --sb-active-bg: rgba(255,255,255,0.08);
    --sb-icon: rgba(255,255,255,0.55);
    --sb-num: rgba(255,255,255,0.35);
  }

  __SIDEBAR_CSS__

  body {
    background: #1a3d28;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    overflow: hidden;
    height: 100vh;
    width: 100vw;
    display: flex;
    flex-direction: row;
  }

  #main {
    flex: 1;
    position: relative;
    overflow: hidden;
  }

  canvas {
    position: absolute;
    inset: 0;
    width: 100%; height: 100%;
  }

  #header {
    position: absolute;
    top: 0; left: 0; right: 0;
    padding: 22px 36px 18px;
    display: flex;
    align-items: flex-start;
    border-bottom: 1px solid rgba(255,255,255,0.15);
    z-index: 10;
  }

  .header-left { display:flex; flex-direction:column; gap:4px; min-width:200px; }

  .series-label {
    font-size: 10px; letter-spacing: 0.14em;
    color: rgba(255,255,255,0.55); text-transform: uppercase; font-weight: 400;
  }

  .rfx-num {
    font-size: 64px; font-weight: 700; color: #ffffff;
    line-height: 0.88; letter-spacing: -0.03em;
  }

  .rfx-num span {
    font-size: 22px; font-weight: 300; vertical-align: top;
    padding-top: 8px; display: inline-block; letter-spacing: 0.01em;
  }

  .divider {
    width: 1px; background: rgba(255,255,255,0.18);
    margin: 0 32px; align-self: stretch;
  }

  .header-center { display:flex; flex-direction:column; justify-content:center; gap:10px; padding:4px 0; }

  .header-title {
    font-size: 20px; font-weight: 400; color: rgba(255,255,255,0.85);
    line-height: 1.25; letter-spacing: -0.01em; max-width: 420px;
  }

  .header-link {
    display: flex; align-items: center; gap: 8px;
    font-size: 10.5px; letter-spacing: 0.10em;
    color: rgba(255,255,255,0.50); text-transform: uppercase;
  }
  .header-link::before { content: '→'; font-size: 12px; }

  .header-right {
    margin-left: auto; display: flex; flex-direction: column;
    align-items: flex-end; justify-content: space-between; padding: 4px 0; gap: 24px;
  }

  .icon-circle { width: 28px; height: 28px; }

  .copyright { font-size: 10px; letter-spacing: 0.08em; color: rgba(255,255,255,0.35); }
</style>
</head>
<body>

__SIDEBAR_HTML__

<div id="main">
  <canvas id="c"></canvas>

  <div id="header">
    <div class="header-left">
      <div class="series-label">Vector Flow Series</div>
      <div class="rfx-num">RFX<span> #10</span></div>
    </div>
    <div class="divider"></div>
    <div class="header-center">
      <div class="header-title">Toroidal Geometry → Continuous Surface<br>through Parametric Projection</div>
      <div class="header-link">vector-flow.io</div>
    </div>
    <div class="header-right">
      <svg class="icon-circle" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
        <circle cx="14" cy="14" r="12" stroke="rgba(255,255,255,0.70)" stroke-width="1.4"/>
        <circle cx="14" cy="14" r="6.5" stroke="rgba(255,255,255,0.70)" stroke-width="1.0"/>
        <circle cx="14" cy="14" r="2" fill="rgba(255,255,255,0.70)"/>
      </svg>
      <div class="copyright">©2025</div>
    </div>
  </div>
</div>

<script>
__SIDEBAR_JS__
(function () {
  const canvas = document.getElementById('c');
  const ctx    = canvas.getContext('2d');
  const main   = document.getElementById('main');
  let DPR = window.devicePixelRatio || 1;
  let W, H;

  function resize() {
    DPR = window.devicePixelRatio || 1;
    W = main.clientWidth;
    H = main.clientHeight;
    canvas.width  = W * DPR;
    canvas.height = H * DPR;
    canvas.style.width  = W + 'px';
    canvas.style.height = H + 'px';
  }
  window.addEventListener('resize', resize);
  document.getElementById('sidebar').addEventListener('transitionend', resize);
  resize();

  const R  = 0.42;
  const r  = 0.16;
  const Nu = 22;
  const Nv = 26;
  const TILT_X     = -34 * Math.PI / 180;
  const FOV_FACTOR = 1.55;
  const DEPTH_PUSH = 0.64;

  let angle = 0;

  function rotX(p, a) {
    const ca = Math.cos(a), sa = Math.sin(a);
    return { x: p.x, y: p.y*ca - p.z*sa, z: p.y*sa + p.z*ca };
  }
  function rotY(p, a) {
    const ca = Math.cos(a), sa = Math.sin(a);
    return { x: p.x*ca + p.z*sa, y: p.y, z: -p.x*sa + p.z*ca };
  }

  function draw() {
    const cw = canvas.width, ch = canvas.height;
    const half = Math.min(cw, ch) / 2;
    const cx   = cw / 2, cy = ch / 2;
    const SCALE     = half;
    const FOV       = FOV_FACTOR * SCALE;
    const DEPTH_OFF = DEPTH_PUSH  * SCALE;
    const Rp = R * SCALE, rp = r * SCALE;

    ctx.fillStyle = '#1a3d28';
    ctx.fillRect(0, 0, cw, ch);

    function project(p) {
      const s = FOV / (FOV + p.z + DEPTH_OFF);
      return { sx: cx + p.x * s, sy: cy + p.y * s, z3d: p.z };
    }

    const grid = [];
    for (let i = 0; i < Nu; i++) {
      grid[i] = [];
      const u = (i / Nu) * 2 * Math.PI;
      for (let j = 0; j < Nv; j++) {
        const v = (j / Nv) * 2 * Math.PI;
        let p = {
          x: (Rp + rp * Math.cos(v)) * Math.cos(u),
          y: (Rp + rp * Math.cos(v)) * Math.sin(u),
          z: rp * Math.sin(v)
        };
        p = rotY(p, angle);
        p = rotX(p, TILT_X);
        grid[i][j] = project(p);
      }
    }

    const maxZ = Rp + rp;
    function depthOp(z, lo, hi) {
      const t = 1 - (z + maxZ) / (2 * maxZ);
      return lo + t * (hi - lo);
    }

    const edges = [];
    for (let i = 0; i < Nu; i++) {
      for (let j = 0; j < Nv; j++) {
        const a = grid[i][j];
        const b = grid[(i+1) % Nu][j];
        const c = grid[i][(j+1) % Nv];
        edges.push({ x1:a.sx, y1:a.sy, x2:b.sx, y2:b.sy, avgZ:(a.z3d+b.z3d)/2 });
        edges.push({ x1:a.sx, y1:a.sy, x2:c.sx, y2:c.sy, avgZ:(a.z3d+c.z3d)/2 });
      }
    }
    edges.sort((a, b) => b.avgZ - a.avgZ);

    for (const e of edges) {
      const op = depthOp(e.avgZ, 0.05, 0.55);
      ctx.beginPath();
      ctx.moveTo(e.x1, e.y1);
      ctx.lineTo(e.x2, e.y2);
      ctx.strokeStyle = `rgba(255,255,255,${op.toFixed(3)})`;
      ctx.lineWidth   = 0.75 * DPR;
      ctx.stroke();
    }

    const nodes = [];
    for (let i = 0; i < Nu; i++)
      for (let j = 0; j < Nv; j++)
        nodes.push(grid[i][j]);
    nodes.sort((a, b) => b.z3d - a.z3d);

    for (const p of nodes) {
      const t   = 1 - (p.z3d + maxZ) / (2 * maxZ);
      const op  = 0.12 + t * 0.82;
      const rad = (0.7 + t * 1.8) * DPR;

      ctx.beginPath();
      ctx.arc(p.sx, p.sy, rad * 2.2, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(255,255,255,${(op * 0.08).toFixed(3)})`;
      ctx.fill();

      ctx.beginPath();
      ctx.arc(p.sx, p.sy, rad, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(255,255,255,${op.toFixed(3)})`;
      ctx.fill();
    }

    angle += 0.005;
    requestAnimationFrame(draw);
  }

  draw();
})();
</script>
</body>
</html>"""

TORUS_HTML = (TORUS_HTML
    .replace('__SIDEBAR_CSS__', SIDEBAR_CSS)
    .replace('__SIDEBAR_JS__', SIDEBAR_JS)
    .replace('__SIDEBAR_HTML__', sidebar_html('torus'))
)


# ══════════════════════════════════════════════════════════════════
#  MILESTONE TRACKING — Snapshot System (/cube)
# ══════════════════════════════════════════════════════════════════

import threading as _threading
from datetime import datetime as _mdt

_MILESTONE_DIR     = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'settings')
SNAPSHOT_PATH      = os.path.join(_MILESTONE_DIR, 'milestone_snapshots.jsonl')
SNAPSHOT_CFG_PATH  = os.path.join(_MILESTONE_DIR, 'snapshot_config.json')

_snap_interval_h   = 3.0
_snap_stop         = _threading.Event()

def _load_snap_config():
    global _snap_interval_h
    if os.path.exists(SNAPSHOT_CFG_PATH):
        try:
            with open(SNAPSHOT_CFG_PATH, 'r') as f:
                _snap_interval_h = float(json.load(f).get('interval_hours', 3.0))
        except Exception:
            pass

def _save_snap_config():
    with open(SNAPSHOT_CFG_PATH, 'w') as f:
        json.dump({'interval_hours': _snap_interval_h}, f)

def load_sap_milestones():
    """±12 Wochen, nur Engines mit definiertem TS-Datum."""
    import pandas as _pd
    from datetime import timedelta as _td
    if not os.path.exists(SAP_DATA_PATH):
        return []
    try:
        df = _pd.read_csv(SAP_DATA_PATH, sep=';', encoding='latin1', on_bad_lines='skip', dtype=str)
        df = df.fillna('')
        for col in ['B1', 'B2', 'B3', 'SA', 'TS', 'TE', 'Customer']:
            if col not in df.columns:
                df[col] = ''
        df['B3_dt'] = _pd.to_datetime(df['B3'], dayfirst=True, errors='coerce')
        df.dropna(subset=['B3_dt'], inplace=True)
        today = _mdt.now()
        df = df[(df['B3_dt'] >= today - _td(weeks=12)) & (df['B3_dt'] <= today + _td(weeks=12))].copy()
        df = df[df['Engine Type'].str.startswith(('CF34','CFM','PW3','PW5','PW8'), na=False)]
        df = df[df['TS'].str.strip() != '']
        df = df.drop_duplicates(subset=['Engine Type', 'Engine Serial', 'Project'])
        def _fmt(val):
            try:
                return _pd.to_datetime(val, dayfirst=True).strftime('%Y-%m-%d')
            except Exception:
                return ''
        result = []
        for _, row in df.iterrows():
            result.append({
                'engineType': row.get('Engine Type', ''),
                'esn':        row.get('Engine Serial', ''),
                'workorder':  row.get('Project', ''),
                'customer':   row.get('Customer', ''),
                'B1': _fmt(row.get('B1', '')),
                'B2': _fmt(row.get('B2', '')),
                'B3': _fmt(row.get('B3', '')),
                'SA': _fmt(row.get('SA', '')),
                'TS': _fmt(row.get('TS', '')),
                'TE': _fmt(row.get('TE', '')),
            })
        print(f'[Milestone] {len(result)} Engines geladen.')
        return result
    except Exception as e:
        print(f'[Milestone] Fehler: {e}')
        return []

_SNAP_MILESTONES = ['B1', 'B2', 'B3', 'SA', 'TS', 'TE']

def _load_last_state():
    """Liest die letzte bekannte Milestone-Zeile pro (esn, workorder) aus der JSONL."""
    last = {}
    if not os.path.exists(SNAPSHOT_PATH):
        return last
    try:
        with open(SNAPSHOT_PATH, 'r', encoding='utf-8') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    r = json.loads(line)
                except Exception:
                    continue
                if r.get('type') == 'heartbeat':
                    continue
                key = (r.get('esn', ''), r.get('workorder', ''))
                last[key] = {m: r.get(m, '') for m in _SNAP_MILESTONES}
    except Exception as e:
        print(f'[Snapshot] _load_last_state Fehler: {e}')
    return last

def write_snapshot():
    engines = load_sap_milestones()
    if not engines:
        return
    ts = _mdt.now().isoformat(timespec='seconds')
    last_state = _load_last_state()
    changed = [
        e for e in engines
        if (e['esn'], e['workorder']) not in last_state
        or any(e.get(m, '') != last_state[(e['esn'], e['workorder'])].get(m, '') for m in _SNAP_MILESTONES)
    ]
    with open(SNAPSHOT_PATH, 'a', encoding='utf-8') as f:
        for e in changed:
            f.write(json.dumps({'type': 'change', 'ts': ts, **e}, ensure_ascii=False) + '\n')
        f.write(json.dumps({'type': 'heartbeat', 'ts': ts, 'checked': len(engines), 'changed': len(changed)}, ensure_ascii=False) + '\n')
    print(f'[Snapshot] {len(changed)} Änderungen / {len(engines)} Engines @ {ts}')

def _snapshot_loop():
    _load_snap_config()
    while not _snap_stop.is_set():
        write_snapshot()
        _snap_stop.wait(_snap_interval_h * 3600)

_threading.Thread(target=_snapshot_loop, daemon=True).start()

def compute_drift():
    """Liest JSONL, berechnet Drift-Counts und History pro Engine+WO."""
    if not os.path.exists(SNAPSHOT_PATH):
        return {'data': [], 'last_heartbeat': None}
    rows = []
    last_heartbeat = None
    try:
        with open(SNAPSHOT_PATH, 'r', encoding='utf-8') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    r = json.loads(line)
                except Exception:
                    continue
                if r.get('type') == 'heartbeat':
                    last_heartbeat = r
                    continue
                rows.append(r)
    except Exception as e:
        print(f'[Drift] Lesefehler: {e}')
        return {'data': [], 'last_heartbeat': None}
    from collections import defaultdict
    groups = defaultdict(list)
    for r in rows:
        groups[(r.get('esn',''), r.get('workorder',''))].append(r)
    result = []
    for (esn, wo), entries in groups.items():
        entries.sort(key=lambda x: x['ts'])
        drift_counts = {m: 0 for m in _SNAP_MILESTONES}
        history = []
        prev = None
        for entry in entries:
            snap = {'ts': entry['ts']}
            for m in _SNAP_MILESTONES:
                snap[m] = entry.get(m, '')
            history.append(snap)
            if prev:
                for m in _SNAP_MILESTONES:
                    if prev.get(m) and entry.get(m) and prev[m] != entry[m]:
                        drift_counts[m] += 1
            prev = entry
        last = entries[-1]
        result.append({
            'esn':        esn,
            'workorder':  wo,
            'engineType': last.get('engineType', ''),
            'customer':   last.get('customer', ''),
            'drifts':     drift_counts,
            'history':    history,
            'last_snap':  last['ts'],
        })
    result.sort(key=lambda x: sum(x['drifts'].values()), reverse=True)
    return {'data': result, 'last_heartbeat': last_heartbeat}


# ══════════════════════════════════════════════════════════════════
#  ROUTES
# ══════════════════════════════════════════════════════════════════

@app.route('/palantir')
def palantir():
    sap_data = load_sap_data()
    html = PALANTIR_HTML.replace('__SAP_DATA__', json.dumps(sap_data, ensure_ascii=False))
    return html


@app.route('/')
def index():
    from flask import redirect
    return redirect('/palantir')

@app.route('/fan')
def fan():
    return HTML


@app.route('/wave')
def wave():
    return WAVE_HTML


@app.route('/torus')
def torus():
    return TORUS_HTML


# ══════════════════════════════════════════════════════════════════
#  CUBE PAGE  (/cube)
# ══════════════════════════════════════════════════════════════════

CUBE_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RFX — Cube</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  :root {
    --sb-bg: rgba(230,227,218,0.95);
    --sb-border: rgba(80,58,38,0.14);
    --sb-label: rgba(80,58,38,0.55);
    --sb-accent: #5c4a35;
    --sb-hover: rgba(92,74,53,0.07);
    --sb-hover-border: rgba(92,74,53,0.40);
    --sb-active-bg: rgba(92,74,53,0.10);
    --sb-icon: rgba(80,58,38,0.60);
    --sb-num: rgba(80,58,38,0.40);
  }

  __SIDEBAR_CSS__

  body {
    background: #edebe4;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    overflow: hidden;
    height: 100vh;
    width: 100vw;
    display: flex;
    flex-direction: row;
  }

  #main {
    flex: 1;
    overflow-y: auto;
    height: 100vh;
  }

  #cube-section {
    height: 100vh;
    position: relative;
    overflow: hidden;
  }

  canvas {
    position: absolute;
    inset: 0;
    width: 100%; height: 100%;
  }

  /* ── Milestone Tracking Section ── */
  #milestone-section {
    background: #e8e4da;
    border-top: 2px solid rgba(80,58,38,0.18);
    padding: 0 0 60px 0;
    min-height: 60vh;
  }

  .ms-bar {
    display: flex; align-items: center; justify-content: space-between;
    padding: 18px 36px 14px;
    border-bottom: 1px solid rgba(80,58,38,0.14);
    background: rgba(230,227,218,0.7);
    position: sticky; top: 0; z-index: 20;
    backdrop-filter: blur(6px);
  }

  .ms-title {
    font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
    color: rgba(80,58,38,0.7); font-weight: 500;
  }

  .ms-subtitle {
    font-size: 10px; color: rgba(80,58,38,0.45); margin-left: 12px;
    letter-spacing: 0.06em;
  }

  .ms-bar-right {
    display: flex; align-items: center; gap: 10px;
  }

  .ms-snap-info {
    font-size: 10px; color: rgba(80,58,38,0.50); letter-spacing: 0.05em;
  }

  .ms-select {
    font-size: 11px; padding: 4px 8px; border-radius: 4px;
    border: 1px solid rgba(80,58,38,0.25); background: #efe9de;
    color: #5c4a35; cursor: pointer;
  }

  .ms-btn {
    font-size: 10.5px; letter-spacing: 0.08em; padding: 5px 14px;
    border-radius: 4px; border: 1px solid rgba(80,58,38,0.30);
    background: transparent; color: #5c4a35; cursor: pointer;
    text-transform: uppercase; transition: background 0.15s;
  }
  .ms-btn:hover { background: rgba(80,58,38,0.08); }

  .ms-matrix-wrap {
    padding: 24px 36px 0;
    overflow-x: auto;
  }

  .ms-matrix {
    width: 100%; border-collapse: collapse; font-size: 12px;
  }

  .ms-matrix thead th {
    font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase;
    color: rgba(80,58,38,0.55); padding: 0 10px 10px; text-align: center;
    border-bottom: 1px solid rgba(80,58,38,0.15);
  }

  .ms-matrix thead .ms-th-engine { text-align: left; }

  .ms-row {
    cursor: pointer; border-bottom: 1px solid rgba(80,58,38,0.08);
    transition: background 0.12s;
  }
  .ms-row:hover { background: rgba(80,58,38,0.04); }

  .ms-td-engine {
    padding: 10px 10px 10px 0; vertical-align: middle;
    display: flex; flex-direction: column; gap: 2px; min-width: 200px;
  }

  .ms-type { font-size: 12px; font-weight: 600; color: #5c4a35; }
  .ms-wo   { font-size: 11px; color: rgba(80,58,38,0.70); }
  .ms-cust { font-size: 10px; color: rgba(80,58,38,0.45); }

  .ms-td-dot { text-align: center; padding: 10px 6px; vertical-align: middle; }

  .ms-dot {
    display: inline-flex; align-items: center; justify-content: center;
    width: 26px; height: 26px; border-radius: 50%;
    font-size: 10px; font-weight: 700; color: #fff;
    transition: transform 0.1s;
  }
  .ms-row:hover .ms-dot { transform: scale(1.12); }

  .ms-td-total { padding: 10px 0 10px 14px; vertical-align: middle; min-width: 120px; }

  .ms-bar-wrap {
    display: flex; align-items: center; gap: 8px;
  }

  .ms-bar-bg {
    flex: 1; height: 6px; border-radius: 3px;
    background: rgba(80,58,38,0.10); overflow: hidden;
  }

  .ms-bar-fill {
    height: 100%; border-radius: 3px; transition: width 0.4s;
  }

  .ms-bar-val {
    font-size: 11px; font-weight: 600; color: rgba(80,58,38,0.65);
    min-width: 20px; text-align: right;
  }

  .ms-empty {
    padding: 40px; text-align: center; color: rgba(80,58,38,0.45);
    font-size: 12px; letter-spacing: 0.05em;
  }

  /* ── MTA Modal ── */
  .mta-modal {
    position: fixed; inset: 0; z-index: 200;
    background: rgba(30,24,18,0.55); backdrop-filter: blur(4px);
    display: flex; align-items: center; justify-content: center;
  }
  .mta-modal.hidden { display: none; }

  .mta-panel {
    background: #faf8f4; border-radius: 10px;
    width: min(900px, 96vw); max-height: 90vh;
    display: flex; flex-direction: column;
    box-shadow: 0 24px 60px rgba(0,0,0,0.22);
    overflow: hidden;
  }

  .mta-header {
    display: flex; align-items: baseline; gap: 12px;
    padding: 18px 24px 14px;
    border-bottom: 1px solid rgba(80,58,38,0.14);
    flex-shrink: 0;
  }

  .mta-title { font-size: 15px; font-weight: 600; color: #5c4a35; }
  .mta-sub {
    font-size: 10px; letter-spacing: 0.10em; text-transform: uppercase;
    color: rgba(80,58,38,0.50); flex: 1;
  }

  .mta-close {
    font-size: 14px; background: none; border: none; cursor: pointer;
    color: rgba(80,58,38,0.55); padding: 4px 8px; border-radius: 4px;
  }
  .mta-close:hover { background: rgba(80,58,38,0.08); }

  .mta-chart-wrap {
    padding: 20px 24px; overflow-y: auto; flex: 1;
  }

  .mta-legend {
    display: flex; gap: 16px; flex-wrap: wrap;
    padding: 0 24px 16px; flex-shrink: 0;
  }

  .mta-leg-item {
    display: flex; align-items: center; gap: 5px;
    font-size: 10.5px; color: rgba(80,58,38,0.70); letter-spacing: 0.05em;
  }

  .mta-leg-dot {
    width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
  }

  #header {
    position: absolute;
    top: 0; left: 0; right: 0;
    padding: 22px 36px 18px;
    display: flex;
    align-items: flex-start;
    border-bottom: 1px solid rgba(80,58,38,0.18);
    z-index: 10;
  }

  .header-left { display:flex; flex-direction:column; gap:4px; min-width:200px; }

  .series-label {
    font-size: 10px; letter-spacing: 0.14em;
    color: rgba(80,58,38,0.70); text-transform: uppercase; font-weight: 400;
  }

  .rfx-num {
    font-size: 64px; font-weight: 700; color: #5c4a35;
    line-height: 0.88; letter-spacing: -0.03em;
  }

  .rfx-num span {
    font-size: 22px; font-weight: 300; vertical-align: top;
    padding-top: 8px; display: inline-block; letter-spacing: 0.01em;
  }

  .divider {
    width: 1px; background: rgba(80,58,38,0.22);
    margin: 0 32px; align-self: stretch;
  }

  .header-center { display:flex; flex-direction:column; justify-content:center; gap:10px; padding:4px 0; }

  .header-title {
    font-size: 20px; font-weight: 400; color: #5c4a35;
    line-height: 1.25; letter-spacing: -0.01em; max-width: 420px;
  }

  .header-link {
    display: flex; align-items: center; gap: 8px;
    font-size: 10.5px; letter-spacing: 0.10em;
    color: rgba(80,58,38,0.65); text-transform: uppercase;
  }
  .header-link::before { content: '→'; font-size: 12px; }

  .header-right {
    margin-left: auto; display: flex; flex-direction: column;
    align-items: flex-end; justify-content: space-between; padding: 4px 0; gap: 24px;
  }

  .icon-circle { width: 28px; height: 28px; }

  .copyright { font-size: 10px; letter-spacing: 0.08em; color: rgba(80,58,38,0.50); }
</style>
</head>
<body>

__SIDEBAR_HTML__

<div id="main">
<div id="cube-section">
  <canvas id="c"></canvas>

  <div id="header">
    <div class="header-left">
      <div class="series-label">Vector Flow Series</div>
      <div class="rfx-num">RFX<span> #11</span></div>
    </div>
    <div class="divider"></div>
    <div class="header-center">
      <div class="header-title">Rubik Mechanics → Layer Rotation<br>through Isometric Projection</div>
      <div class="header-link">vector-flow.io</div>
    </div>
    <div class="header-right">
      <svg class="icon-circle" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
        <circle cx="14" cy="14" r="12" stroke="#5c4a35" stroke-width="1.4"/>
        <circle cx="14" cy="14" r="6.5" stroke="#5c4a35" stroke-width="1.0"/>
        <circle cx="14" cy="14" r="2" fill="#5c4a35"/>
      </svg>
      <div class="copyright">©2025</div>
    </div>
  </div>
</div>

<!-- ── Milestone Tracking ── -->
<div id="milestone-section">
  <div class="ms-bar">
    <div class="ms-bar-left">
      <span class="ms-title">Milestone Tracking</span>
      <span class="ms-subtitle">±12 Wochen · nur Engines mit TS-Datum</span>
    </div>
    <div class="ms-bar-right">
      <span class="ms-snap-info" id="ms-snap-info">—</span>
      <button class="ms-btn" onclick="msSnapshotNow()">Snapshot jetzt</button>
      <label class="ms-title" style="font-size:9.5px">Intervall:</label>
      <select class="ms-select" id="ms-interval">
        <option value="0.5">30 Min</option>
        <option value="1">1 Std</option>
        <option value="3">3 Std</option>
        <option value="6">6 Std</option>
        <option value="12">12 Std</option>
        <option value="24">24 Std</option>
      </select>
      <button class="ms-btn" onclick="msSaveInterval()">Speichern</button>
    </div>
  </div>

  <div class="ms-matrix-wrap">
    <table class="ms-matrix">
      <thead>
        <tr>
          <th class="ms-th-engine">Engine · WO · Kunde</th>
          <th>B1</th><th>B2</th><th>B3</th><th>SA</th><th>TS</th><th>TE</th>
          <th style="text-align:left;padding-left:14px">Gesamt</th>
        </tr>
      </thead>
      <tbody id="ms-tbody"></tbody>
    </table>
  </div>
</div>

<!-- ── MTA Modal ── -->
<div id="mta-modal" class="mta-modal hidden">
  <div class="mta-panel">
    <div class="mta-header">
      <span class="mta-title" id="mta-title">MTA</span>
      <span class="mta-sub">Milestone Trend Analysis · Lent (2013)</span>
      <button class="mta-close" onclick="mtaClose()">✕</button>
    </div>
    <div class="mta-legend" id="mta-legend"></div>
    <div class="mta-chart-wrap">
      <svg id="mta-svg" xmlns="http://www.w3.org/2000/svg"></svg>
    </div>
  </div>
</div>

</div>

<script>
__SIDEBAR_JS__
(function () {

  const mulMV = (m, v) => [
    m[0]*v[0]+m[1]*v[1]+m[2]*v[2],
    m[3]*v[0]+m[4]*v[1]+m[5]*v[2],
    m[6]*v[0]+m[7]*v[1]+m[8]*v[2]
  ];
  const mulMM = (a, b) => [
    a[0]*b[0]+a[1]*b[3]+a[2]*b[6], a[0]*b[1]+a[1]*b[4]+a[2]*b[7], a[0]*b[2]+a[1]*b[5]+a[2]*b[8],
    a[3]*b[0]+a[4]*b[3]+a[5]*b[6], a[3]*b[1]+a[4]*b[4]+a[5]*b[7], a[3]*b[2]+a[4]*b[5]+a[5]*b[8],
    a[6]*b[0]+a[7]*b[3]+a[8]*b[6], a[6]*b[1]+a[7]*b[4]+a[8]*b[7], a[6]*b[2]+a[7]*b[5]+a[8]*b[8]
  ];
  const I3 = () => [1,0,0, 0,1,0, 0,0,1];
  const rotXM = a => { const c=Math.cos(a),s=Math.sin(a); return [1,0,0, 0,c,-s, 0,s,c]; };
  const rotYM = a => { const c=Math.cos(a),s=Math.sin(a); return [c,0,s, 0,1,0,-s,0,c]; };
  const rotZM = a => { const c=Math.cos(a),s=Math.sin(a); return [c,-s,0, s,c,0, 0,0,1]; };
  const getAxisRot = (ax, a) => ax===0 ? rotXM(a) : ax===1 ? rotYM(a) : rotZM(a);

  const ISO_ANGLE = Math.asin(1/Math.sqrt(3));
  const VIEW = mulMM(rotXM(ISO_ANGLE), rotYM(-Math.PI/4));

  const SHADES = { top: '#edebe4', right: '#e4e1d9', front: '#d9d6ce' };

  function faceColor(worldNormal) {
    const vn = mulMV(VIEW, worldNormal);
    if (vn[2] <= 0.01) return null;
    const [nx, ny, nz] = worldNormal;
    const ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
    if (ay >= ax && ay >= az) return ny > 0 ? SHADES.top  : null;
    if (ax >= az)             return nx > 0 ? SHADES.right : null;
    return nz > 0 ? SHADES.front : null;
  }

  const H = 0.497;
  const FACE_DEFS = [
    { n:[1,0,0],  v:[[ H,-H,-H],[ H, H,-H],[ H, H, H],[ H,-H, H]] },
    { n:[-1,0,0], v:[[-H,-H, H],[-H, H, H],[-H, H,-H],[-H,-H,-H]] },
    { n:[0,1,0],  v:[[-H, H,-H],[ H, H,-H],[ H, H, H],[-H, H, H]] },
    { n:[0,-1,0], v:[[-H,-H, H],[ H,-H, H],[ H,-H,-H],[-H,-H,-H]] },
    { n:[0,0,1],  v:[[-H,-H, H],[ H,-H, H],[ H, H, H],[-H, H, H]] },
    { n:[0,0,-1], v:[[ H,-H,-H],[-H,-H,-H],[-H, H,-H],[ H, H,-H]] },
  ];

  const cubies = [];
  for (let x=-1;x<=1;x++) for (let y=-1;y<=1;y++) for (let z=-1;z<=1;z++)
    cubies.push({ pos:[x,y,z], ori:I3() });

  let anim = null;
  let nextMoveAt = performance.now() + 700;
  const ANIM_MS  = 440;
  const PAUSE_MS = 1100;

  function triggerMove() {
    const axis  = Math.floor(Math.random() * 3);
    const layer = [-1, 0, 1][Math.floor(Math.random() * 3)];
    const dir   = Math.random() < 0.5 ? 1 : -1;
    anim = { axis, layer, dir, t: 0 };
  }

  function finishAnim() {
    const { axis, layer, dir } = anim;
    const rot = getAxisRot(axis, dir * Math.PI / 2);
    for (const c of cubies) {
      if (Math.round(c.pos[axis]) !== layer) continue;
      c.pos = mulMV(rot, c.pos).map(Math.round);
      c.ori = mulMM(rot, c.ori);
    }
    anim = null;
    nextMoveAt = performance.now() + PAUSE_MS;
  }

  const ease = t => t < 0.5 ? 2*t*t : -1 + (4 - 2*t) * t;

  const canvas    = document.getElementById('c');
  const ctx       = canvas.getContext('2d');
  const cubeSection = document.getElementById('cube-section');
  const DPR       = window.devicePixelRatio || 1;

  let UNIT, CX, CY;

  function resize() {
    const S = Math.min(cubeSection.clientWidth, cubeSection.clientHeight);
    UNIT = S * 52 / 500;
    CX   = cubeSection.clientWidth  / 2;
    CY   = cubeSection.clientHeight / 2 + S * 12 / 500;
    canvas.width  = cubeSection.clientWidth  * DPR;
    canvas.height = cubeSection.clientHeight * DPR;
    canvas.style.width  = cubeSection.clientWidth  + 'px';
    canvas.style.height = cubeSection.clientHeight + 'px';
  }
  window.addEventListener('resize', resize);
  document.getElementById('sidebar').addEventListener('transitionend', resize);
  resize();

  function toScreen(p) {
    const v = mulMV(VIEW, p);
    return { sx: CX + v[0]*UNIT, sy: CY - v[1]*UNIT, z: v[2] };
  }

  let lastTime = null;

  function frame(now) {
    if (!lastTime) lastTime = now;
    const dt = now - lastTime;
    lastTime  = now;

    if (!anim && now >= nextMoveAt) triggerMove();
    if (anim) {
      anim.t = Math.min(1, anim.t + dt / ANIM_MS);
      if (anim.t >= 1) finishAnim();
    }

    ctx.save();
    ctx.scale(DPR, DPR);
    ctx.clearRect(0, 0, canvas.width / DPR, canvas.height / DPR);

    const allFaces = [];

    for (const c of cubies) {
      let wOri = c.ori;
      let wPos = c.pos;

      if (anim && Math.round(c.pos[anim.axis]) === anim.layer) {
        const angle = anim.dir * (Math.PI/2) * ease(anim.t);
        const lRot  = getAxisRot(anim.axis, angle);
        wOri = mulMM(lRot, c.ori);
        wPos = mulMV(lRot, c.pos);
      }

      for (const fd of FACE_DEFS) {
        const wn    = mulMV(wOri, fd.n);
        const color = faceColor(wn);
        if (!color) continue;

        const pts = fd.v.map(lv => {
          const rv = mulMV(wOri, lv);
          const wp = [rv[0]+wPos[0], rv[1]+wPos[1], rv[2]+wPos[2]];
          return toScreen(wp);
        });

        const avgZ = (pts[0].z + pts[1].z + pts[2].z + pts[3].z) * 0.25;
        allFaces.push({ pts, color, z: avgZ });
      }
    }

    allFaces.sort((a, b) => a.z - b.z);

    ctx.lineJoin    = 'round';
    ctx.lineWidth   = 1.3;
    ctx.strokeStyle = '#1a1815';

    for (const f of allFaces) {
      ctx.beginPath();
      ctx.moveTo(f.pts[0].sx, f.pts[0].sy);
      ctx.lineTo(f.pts[1].sx, f.pts[1].sy);
      ctx.lineTo(f.pts[2].sx, f.pts[2].sy);
      ctx.lineTo(f.pts[3].sx, f.pts[3].sy);
      ctx.closePath();
      ctx.fillStyle = f.color;
      ctx.fill();
      ctx.stroke();
    }

    ctx.restore();
    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
})();

// ══════════════════════════════════════════════════════════════════
//  MILESTONE TRACKING
// ══════════════════════════════════════════════════════════════════

const MS_COLORS = {B1:'#4a7fb5',B2:'#7b5ea7',B3:'#c94040',SA:'#d4873a',TS:'#3a9a5c',TE:'#3a8a9a'};
const MS_MILESTONES = ['B1','B2','B3','SA','TS','TE'];

function msDriftColor(count, hasDate) {
  if (!hasDate) return '#c8c3ba';
  if (count === 0) return '#6db86d';
  if (count <= 2)  return '#d4a843';
  if (count <= 5)  return '#e07a3a';
  return '#c94040';
}

let msData = [];
let msLastHeartbeat = null;

async function msLoad() {
  try {
    const r = await fetch('/api/milestone-drift');
    const resp = await r.json();
    msData = resp.data || [];
    msLastHeartbeat = resp.last_heartbeat || null;
    msRenderMatrix();
  } catch(e) { console.warn('[MS]', e); }
}

async function msLoadConfig() {
  try {
    const r = await fetch('/api/snapshot-config');
    const cfg = await r.json();
    const sel = document.getElementById('ms-interval');
    sel.value = String(cfg.interval_hours);
    if (!sel.value) sel.value = '3';
  } catch(e) {}
}

async function msSaveInterval() {
  const h = parseFloat(document.getElementById('ms-interval').value);
  await fetch('/api/snapshot-config', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({interval_hours: h})
  });
  document.getElementById('ms-snap-info').textContent = `Intervall gespeichert: ${h}h ✓`;
}

async function msSnapshotNow() {
  document.getElementById('ms-snap-info').textContent = 'Snapshot läuft…';
  await fetch('/api/snapshot-now', {method: 'POST'});
  document.getElementById('ms-snap-info').textContent = 'Gespeichert ✓';
  msLoad();
}

function msRenderMatrix() {
  const tbody = document.getElementById('ms-tbody');
  tbody.innerHTML = '';
  if (!msData.length) {
    tbody.innerHTML = '<tr><td colspan="8" class="ms-empty">Noch keine Snapshot-Daten — klicke "Snapshot jetzt" um zu starten.</td></tr>';
    return;
  }
  if (msLastHeartbeat) {
    const hb = msLastHeartbeat;
    const info = `Geprüft: ${hb.ts.replace('T',' ')} · ${hb.checked} Engines · ${hb.changed} Änderung${hb.changed!==1?'en':''}`;
    document.getElementById('ms-snap-info').textContent = info;
  }
  const maxTotal = Math.max(...msData.map(e => MS_MILESTONES.reduce((s,m)=>s+e.drifts[m],0)), 1);

  for (const eng of msData) {
    const total = MS_MILESTONES.reduce((s,m)=>s+eng.drifts[m],0);
    const tr = document.createElement('tr');
    tr.className = 'ms-row';
    tr.onclick = () => mtaOpen(eng);

    const tdEng = document.createElement('td');
    tdEng.className = 'ms-td-engine';
    tdEng.innerHTML = `<span class="ms-type">${eng.engineType}</span><span class="ms-wo">${eng.workorder}</span><span class="ms-cust">${eng.customer}</span>`;
    tr.appendChild(tdEng);

    const lastSnap = eng.history[eng.history.length - 1] || {};
    for (const m of MS_MILESTONES) {
      const td = document.createElement('td');
      td.className = 'ms-td-dot';
      const hasDate = !!lastSnap[m];
      const count   = eng.drifts[m];
      const color   = msDriftColor(count, hasDate);
      td.innerHTML  = `<span class="ms-dot" style="background:${color}" title="${m}: ${count}× verschoben${hasDate ? '' : ' (kein Datum)'}">${hasDate ? (count||'·') : '—'}</span>`;
      tr.appendChild(td);
    }

    const tdTotal = document.createElement('td');
    tdTotal.className = 'ms-td-total';
    const pct = Math.round(total / maxTotal * 100);
    tdTotal.innerHTML = `<div class="ms-bar-wrap"><div class="ms-bar-bg" style="flex:1"><div class="ms-bar-fill" style="width:${pct}%;background:${msDriftColor(total,true)}"></div></div><span class="ms-bar-val">${total}</span></div>`;
    tr.appendChild(tdTotal);

    tbody.appendChild(tr);
  }
}

// ── MTA Chart ─────────────────────────────────────────────────────
function mtaOpen(eng) {
  document.getElementById('mta-title').textContent =
    `${eng.engineType}  ·  ${eng.workorder}  ·  ESN ${eng.esn}  ·  ${eng.customer}`;
  mtaBuildLegend();
  mtaDrawChart(eng);
  document.getElementById('mta-modal').classList.remove('hidden');
}

function mtaClose() {
  document.getElementById('mta-modal').classList.add('hidden');
}

document.getElementById('mta-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) mtaClose();
});

function mtaBuildLegend() {
  document.getElementById('mta-legend').innerHTML =
    MS_MILESTONES.map(m =>
      `<span class="mta-leg-item"><span class="mta-leg-dot" style="background:${MS_COLORS[m]}"></span>${m}</span>`
    ).join('');
}

function mtaDrawChart(eng) {
  const svg = document.getElementById('mta-svg');
  const wrap = svg.parentElement;
  const W = Math.max(500, wrap.clientWidth - 48);
  const H = 380;
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
  svg.setAttribute('width', W);
  svg.setAttribute('height', H);
  svg.innerHTML = '';

  const PAD = {t:24, r:24, b:56, l:88};
  const CW  = W - PAD.l - PAD.r;
  const CH  = H - PAD.t - PAD.b;

  const snapTs = eng.history.map(h => new Date(h.ts).getTime());
  const allMs  = [];
  for (const h of eng.history)
    for (const m of MS_MILESTONES)
      if (h[m]) allMs.push(new Date(h[m]).getTime());

  if (!snapTs.length || !allMs.length) {
    const t = document.createElementNS('http://www.w3.org/2000/svg','text');
    t.setAttribute('x', W/2); t.setAttribute('y', H/2);
    t.setAttribute('text-anchor','middle'); t.setAttribute('fill','rgba(80,58,38,0.45)');
    t.setAttribute('font-size','13'); t.textContent = 'Noch keine History-Daten';
    svg.appendChild(t); return;
  }

  const xMin = Math.min(...snapTs);
  const xMax = Math.max(...snapTs, Date.now());
  const pad5 = 86400000 * 5;
  const yMin = Math.min(...allMs) - pad5;
  const yMax = Math.max(...allMs) + pad5;

  const xS = t => PAD.l + (t - xMin) / (xMax - xMin || 1) * CW;
  const yS = t => PAD.t + CH - (t - yMin) / (yMax - yMin || 1) * CH;
  const fmtD = t => new Date(t).toISOString().slice(0,10);

  function el(tag, attrs, parent) {
    const e = document.createElementNS('http://www.w3.org/2000/svg', tag);
    for (const [k,v] of Object.entries(attrs)) e.setAttribute(k, v);
    if (parent) parent.appendChild(e);
    return e;
  }

  // Chart background
  el('rect', {x:PAD.l,y:PAD.t,width:CW,height:CH,fill:'#faf8f4','stroke':'rgba(80,58,38,0.12)'}, svg);

  // Diagonal "on-time" reference
  const dMin = Math.max(xMin, yMin), dMax = Math.min(xMax, yMax);
  if (dMax > dMin) {
    el('line', {x1:xS(dMin),y1:yS(dMin),x2:xS(dMax),y2:yS(dMax),
      stroke:'rgba(80,58,38,0.28)','stroke-width':'1.5','stroke-dasharray':'7,4'}, svg);
    const lbl = el('text', {x:xS(dMax)+5,y:yS(dMax)+4,
      fill:'rgba(80,58,38,0.40)','font-size':'9','font-style':'italic'}, svg);
    lbl.textContent = 'on-time';
  }

  // Grid lines + axis labels
  for (let i=0; i<=4; i++) {
    const xt = xMin + (xMax-xMin)*i/4;
    const x  = xS(xt);
    el('line', {x1:x,y1:PAD.t,x2:x,y2:PAD.t+CH,stroke:'rgba(80,58,38,0.07)','stroke-width':'1'}, svg);
    el('line', {x1:x,y1:PAD.t+CH,x2:x,y2:PAD.t+CH+5,stroke:'rgba(80,58,38,0.35)','stroke-width':'1'}, svg);
    const lb = el('text', {x:x,y:PAD.t+CH+17,'text-anchor':'middle','font-size':'8.5',fill:'rgba(80,58,38,0.60)'}, svg);
    lb.textContent = fmtD(xt);
  }
  for (let i=0; i<=4; i++) {
    const yt = yMin + (yMax-yMin)*i/4;
    const y  = yS(yt);
    el('line', {x1:PAD.l,y1:y,x2:PAD.l+CW,y2:y,stroke:'rgba(80,58,38,0.07)','stroke-width':'1'}, svg);
    el('line', {x1:PAD.l-5,y1:y,x2:PAD.l,y2:y,stroke:'rgba(80,58,38,0.35)','stroke-width':'1'}, svg);
    const lb = el('text', {x:PAD.l-8,y:y+3,'text-anchor':'end','font-size':'8.5',fill:'rgba(80,58,38,0.60)'}, svg);
    lb.textContent = fmtD(yt);
  }

  // Axis labels
  const xLbl = el('text', {x:PAD.l+CW/2,y:H-4,'text-anchor':'middle','font-size':'9',
    fill:'rgba(80,58,38,0.50)','letter-spacing':'0.08em'}, svg);
  xLbl.textContent = 'BERICHTSDATUM';
  const yLbl = el('text', {x:12,y:PAD.t+CH/2,'text-anchor':'middle','font-size':'9',
    fill:'rgba(80,58,38,0.50)','letter-spacing':'0.08em',
    transform:`rotate(-90,12,${PAD.t+CH/2})`}, svg);
  yLbl.textContent = 'MEILENSTEIN-PROGNOSE';

  // Milestone lines
  for (const m of MS_MILESTONES) {
    const pts = [];
    for (const h of eng.history)
      if (h[m]) pts.push([xS(new Date(h.ts).getTime()), yS(new Date(h[m]).getTime()), h.ts, h[m]]);
    if (!pts.length) continue;

    if (pts.length > 1) {
      const d = 'M ' + pts.map(p=>`${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' L ');
      el('path', {d, stroke:MS_COLORS[m],'stroke-width':'2',fill:'none','stroke-linejoin':'round'}, svg);
    }
    for (const [px,py,ts,date] of pts) {
      const c = el('circle', {cx:px.toFixed(1),cy:py.toFixed(1),r:'3.5',fill:MS_COLORS[m]}, svg);
      c.setAttribute('title', `${m}  ${date}  (@ ${ts.slice(0,10)})`);
    }
    const last = pts[pts.length-1];
    const lb = el('text', {x:(last[0]+7).toFixed(1),y:(last[1]+4).toFixed(1),
      'font-size':'10','font-weight':'600',fill:MS_COLORS[m]}, svg);
    lb.textContent = m;
  }
}

// Init
msLoad();
msLoadConfig();
</script>
</body>
</html>"""

CUBE_HTML = (CUBE_HTML
    .replace('__SIDEBAR_CSS__', SIDEBAR_CSS)
    .replace('__SIDEBAR_JS__', SIDEBAR_JS)
    .replace('__SIDEBAR_HTML__', sidebar_html('cube'))
)


@app.route('/cube')
def cube():
    return CUBE_HTML

@app.route('/api/milestone-drift')
def api_milestone_drift():
    return jsonify(compute_drift())

@app.route('/api/snapshot-config', methods=['GET', 'POST'])
def api_snapshot_config():
    global _snap_interval_h, _snap_stop
    if request.method == 'POST':
        data = request.get_json(force=True)
        _snap_interval_h = max(0.5, min(24.0, float(data.get('interval_hours', 3.0))))
        _save_snap_config()
        _snap_stop.set()
        _snap_stop.clear()
        _threading.Thread(target=_snapshot_loop, daemon=True).start()
        return jsonify({'ok': True, 'interval_hours': _snap_interval_h})
    return jsonify({'interval_hours': _snap_interval_h})

@app.route('/api/snapshot-now', methods=['POST'])
def api_snapshot_now():
    write_snapshot()
    return jsonify({'ok': True})


# ══════════════════════════════════════════════════════════════════
#  TERRAIN PAGE  (/terrain)
# ══════════════════════════════════════════════════════════════════

TERRAIN_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RFX — Terrain</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  :root {
    --sb-bg: rgba(232,228,216,0.95);
    --sb-border: rgba(26,24,21,0.12);
    --sb-label: rgba(26,24,21,0.50);
    --sb-accent: #1a1815;
    --sb-hover: rgba(26,24,21,0.05);
    --sb-hover-border: rgba(26,24,21,0.35);
    --sb-active-bg: rgba(26,24,21,0.08);
    --sb-icon: rgba(26,24,21,0.55);
    --sb-num: rgba(26,24,21,0.35);
  }

  __SIDEBAR_CSS__

  body {
    background: #e8e4d8;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    overflow: hidden;
    height: 100vh;
    width: 100vw;
    display: flex;
    flex-direction: row;
  }

  #main {
    flex: 1;
    display: flex;
    flex-direction: column;
    position: relative;
    overflow-y: auto;
    overflow-x: hidden;
  }

  #terrain {
    flex: 1;
    width: 100%;
    display: block;
    min-height: 0;
  }

  /* ── Quick-Links Section ── */
  #section-terrain {
    height: 100vh;
    display: flex;
    flex-direction: column;
    flex-shrink: 0;
    position: relative;
    overflow: hidden;
  }

  #section-links {
    min-height: 100vh;
    background: #e8e4d8;
    padding: 80px clamp(40px,6%,80px) 110px;
    display: flex;
    flex-direction: column;
    gap: 80px;
    border-top: 1px solid rgba(26,24,21,0.12);
  }

  /* view + edit shared header row */
  .ql-header-row {
    display: flex; align-items: center; justify-content: space-between;
    margin-bottom: -40px;
  }
  .ql-page-title {
    font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase;
    color: rgba(26,24,21,0.38); font-weight: 400;
  }
  .ql-edit-toggle {
    display: flex; align-items: center; gap: 6px;
    background: none; border: none; cursor: pointer;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
    color: rgba(26,24,21,0.35); padding: 4px 0;
    transition: color 0.14s;
  }
  .ql-edit-toggle:hover { color: #1a1815; }
  .ql-edit-toggle svg { width: 11px; height: 11px; stroke: currentColor; flex-shrink: 0; }

  /* view mode */
  .ql-category { display: flex; flex-direction: column; gap: 26px; }
  .ql-category-label {
    font-size: clamp(30px, 4.2vw, 56px);
    font-weight: 600; color: #1a1815;
    line-height: 1.0; letter-spacing: -0.03em;
    padding-bottom: 22px;
    border-bottom: 1px solid rgba(26,24,21,0.18);
  }
  .ql-items { display: flex; flex-wrap: wrap; gap: 10px; }
  .ql-btn {
    display: flex; align-items: center; gap: 10px;
    padding: 11px 18px 11px 14px;
    background: transparent; border: 1px solid rgba(26,24,21,0.18);
    border-radius: 2px; cursor: pointer;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 11px; font-weight: 500; color: #1a1815;
    letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap;
    transition: background 0.14s, border-color 0.14s;
  }
  .ql-btn:hover { background: rgba(26,24,21,0.07); border-color: rgba(26,24,21,0.38); }
  .ql-btn:active { background: rgba(26,24,21,0.13); }
  .ql-btn svg { width: 13px; height: 13px; stroke: currentColor; opacity: 0.55; flex-shrink: 0; }
  .ql-empty {
    font-size: 11px; color: rgba(26,24,21,0.35);
    letter-spacing: 0.08em; text-transform: uppercase; padding: 20px 0;
  }

  /* edit mode */
  .ql-action-row { display: flex; align-items: center; gap: 10px; }
  .ql-save-btn {
    padding: 8px 22px; background: #1a1815; color: #e8e4d8;
    border: none; cursor: pointer;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
    transition: opacity 0.14s;
  }
  .ql-save-btn:hover { opacity: 0.75; }
  .ql-cancel-btn {
    padding: 8px 18px; background: none; color: rgba(26,24,21,0.45);
    border: 1px solid rgba(26,24,21,0.18); cursor: pointer;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
    transition: color 0.14s, border-color 0.14s;
  }
  .ql-cancel-btn:hover { color: #1a1815; border-color: rgba(26,24,21,0.35); }

  .ql-cat-header {
    display: flex; align-items: flex-end; gap: 20px;
    padding-bottom: 22px; border-bottom: 1px solid rgba(26,24,21,0.18);
  }
  .ql-cat-name-input {
    flex: 1;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: clamp(30px, 4.2vw, 56px);
    font-weight: 600; color: #1a1815;
    line-height: 1.0; letter-spacing: -0.03em;
    background: none; border: none; border-bottom: 2px solid transparent;
    outline: none; padding-bottom: 2px; transition: border-color 0.14s;
  }
  .ql-cat-name-input:focus { border-bottom-color: rgba(26,24,21,0.22); }
  .ql-del-cat {
    background: none; border: none; cursor: pointer; margin-bottom: 4px;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
    color: rgba(26,24,21,0.28); transition: color 0.14s;
  }
  .ql-del-cat:hover { color: rgba(200,60,40,0.70); }

  .ql-item-row { display: flex; align-items: center; gap: 8px; }
  .ql-icon-sel {
    appearance: none; -webkit-appearance: none;
    background: none; border: 1px solid rgba(26,24,21,0.16);
    padding: 6px 8px; outline: none; cursor: pointer;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 9px; letter-spacing: 0.10em; text-transform: uppercase; color: #1a1815;
    min-width: 76px;
  }
  .ql-icon-sel:focus { border-color: rgba(26,24,21,0.40); }
  .ql-item-input {
    background: none; border: none;
    border-bottom: 1px solid rgba(26,24,21,0.16);
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    color: #1a1815; outline: none; padding: 6px 2px;
    transition: border-color 0.14s;
  }
  .ql-item-input:focus { border-bottom-color: #1a1815; }
  .ql-label-inp { width: 150px; font-size: 11px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; }
  .ql-path-inp  { flex: 1; font-size: 11px; font-family: monospace; letter-spacing: 0; }
  .ql-del-item {
    background: none; border: none; cursor: pointer;
    color: rgba(26,24,21,0.22); font-size: 17px; line-height: 1;
    padding: 2px 4px; transition: color 0.14s; flex-shrink: 0;
  }
  .ql-del-item:hover { color: rgba(200,60,40,0.70); }

  .ql-add-item {
    display: inline-flex; align-items: center; gap: 6px;
    background: none; border: 1px dashed rgba(26,24,21,0.18);
    padding: 7px 14px; border-radius: 2px;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
    color: rgba(26,24,21,0.35); cursor: pointer;
    transition: border-color 0.14s, color 0.14s;
  }
  .ql-add-item:hover { border-color: rgba(26,24,21,0.35); color: #1a1815; }
  .ql-add-cat {
    align-self: flex-start;
    display: flex; align-items: center; gap: 8px;
    background: none; border: 1px dashed rgba(26,24,21,0.18);
    padding: 12px 26px; border-radius: 2px;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
    color: rgba(26,24,21,0.35); cursor: pointer;
    transition: border-color 0.14s, color 0.14s;
  }
  .ql-add-cat:hover { border-color: rgba(26,24,21,0.38); color: #1a1815; }

  /* search bar */
  .ql-search-wrap { position: relative; }
  .ql-search-input {
    width: 100%; box-sizing: border-box;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: clamp(24px, 3.4vw, 48px);
    font-weight: 600; color: #1a1815;
    letter-spacing: -0.025em; line-height: 1;
    background: none;
    border: 1px solid rgba(26,24,21,0.18);
    outline: none; padding: 18px 120px 18px 24px;
    transition: border-color 0.18s;
  }
  .ql-search-input::placeholder { color: rgba(26,24,21,0.18); font-weight: 600; }
  .ql-search-input:focus { border-color: rgba(26,24,21,0.48); }
  .ql-search-clear {
    position: absolute; right: 20px; bottom: 50%; transform: translateY(50%);
    background: none; border: none; cursor: pointer;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
    color: rgba(26,24,21,0.35); padding: 4px 0;
    opacity: 0; pointer-events: none; transition: opacity 0.14s, color 0.14s;
  }
  .ql-search-clear.ql-vis { opacity: 1; pointer-events: auto; }
  .ql-search-clear:hover { color: #1a1815; }
  .ql-no-results {
    font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
    color: rgba(26,24,21,0.32); padding: 10px 0; display: none;
  }
  .ql-no-results.ql-vis { display: block; }

  /* drag handles + drop indicators */
  .ql-grip {
    cursor: grab; flex-shrink: 0; padding: 0 5px;
    color: #a09888; transition: color 0.14s; line-height: 0;
  }
  .ql-grip:active { cursor: grabbing; }
  .ql-grip:hover { color: #5c4e3e; }
  .ql-cat-grip { cursor: grab; color: #a09888; transition: color 0.14s; margin-bottom: 6px; line-height: 0; }
  .ql-cat-grip:active { cursor: grabbing; }
  .ql-cat-grip:hover { color: #5c4e3e; }
  .ql-drop-before { box-shadow: 0 -2px 0 rgba(26,24,21,0.50); }
  .ql-drop-after  { box-shadow: 0 2px 0 rgba(26,24,21,0.50); }
  [draggable=true] { user-select: none; }

  .ql-scroll-hint {
    position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%);
    display: flex; flex-direction: column; align-items: center; gap: 7px;
    opacity: 0; animation: qlFadeIn 1s ease 1.5s forwards;
  }
  .ql-scroll-hint-label {
    font-size: 8.5px; letter-spacing: 0.16em; text-transform: uppercase;
    color: #1a1815;
  }
  .ql-arrow {
    width: 1px; height: 22px; background: rgba(26,24,21,0.50);
    position: relative; animation: qlBounce 2s ease-in-out 2.5s infinite;
  }
  .ql-arrow::after {
    content: ''; position: absolute; bottom: -1px; left: -3px;
    width: 7px; height: 7px;
    border-right: 1px solid rgba(26,24,21,0.50);
    border-bottom: 1px solid rgba(26,24,21,0.50);
    transform: rotate(45deg);
  }
  @keyframes qlFadeIn { to { opacity: 0.45; } }
  @keyframes qlBounce {
    0%,100% { transform: translateY(0); }
    50% { transform: translateY(5px); }
  }

  /* ── Header ── */
  #header {
    position: absolute;
    top: 0; left: 0; right: 0;
    padding: 22px 36px 18px;
    display: flex;
    align-items: flex-start;
    border-bottom: 1px solid rgba(26,24,21,0.10);
    z-index: 10;
  }

  .header-left { display:flex; flex-direction:column; gap:4px; min-width:200px; }

  .series-label {
    font-size: 10px; letter-spacing: 0.14em;
    color: rgba(26,24,21,0.60); text-transform: uppercase; font-weight: 400;
  }

  .rfx-num {
    font-size: 64px; font-weight: 700; color: #1a1815;
    line-height: 0.88; letter-spacing: -0.03em;
  }

  .rfx-num span {
    font-size: 22px; font-weight: 300; vertical-align: top;
    padding-top: 8px; display: inline-block; letter-spacing: 0.01em;
  }

  .header-divider {
    width: 1px; background: rgba(26,24,21,0.18);
    margin: 0 32px; align-self: stretch;
  }

  .header-center { display:flex; flex-direction:column; justify-content:center; gap:10px; padding:4px 0; }

  .header-title {
    font-size: 20px; font-weight: 400; color: #1a1815;
    line-height: 1.25; letter-spacing: -0.01em; max-width: 420px;
  }

  .header-link {
    display: flex; align-items: center; gap: 8px;
    font-size: 10.5px; letter-spacing: 0.10em;
    color: rgba(26,24,21,0.55); text-transform: uppercase;
  }
  .header-link::before { content: '→'; font-size: 12px; }

  .header-right {
    margin-left: auto; display: flex; flex-direction: column;
    align-items: flex-end; justify-content: space-between; padding: 4px 0; gap: 24px;
  }

  .icon-circle { width: 28px; height: 28px; }
  .copyright { font-size: 10px; letter-spacing: 0.08em; color: rgba(26,24,21,0.45); }

  /* ── Text Area ── */
  .text-area {
    display: flex; align-items: stretch;
    padding: 20px clamp(28px,5%,60px);
    gap: 0; flex-shrink: 0;
    border-top: 1px solid rgba(26,24,21,0.12);
    background: #e8e4d8;
  }

  .col {
    flex: 1; display: flex; flex-direction: column;
    justify-content: space-between;
    padding: 0 clamp(16px,3%,44px);
  }
  .col:first-child { padding-left: 0; }
  .col:last-child  { padding-right: 0; }

  .col-divider { width:1px; background:rgba(26,24,21,0.25); flex-shrink:0; margin:0 clamp(12px,2%,28px); }

  .col-title {
    font-size: clamp(20px,3vw,44px); font-weight: 400; color: #1a1815;
    line-height: 1.06; letter-spacing: -0.02em;
    flex: 1; display: flex; align-items: flex-start;
  }
  .col-title.heavy { font-weight: 600; }

  .foot { display:flex; align-items:center; gap:10px; margin-top:12px; }
  .foot-label {
    font-size: clamp(7px,0.75vw,9.5px); font-weight: 500;
    letter-spacing: 0.18em; text-transform: uppercase;
    color: #1a1815; white-space: nowrap; line-height: 1.6;
  }
  .foot-rule { flex:1; height:1px; background:#1a1815; opacity:0.5; max-width:120px; }

  .meta {
    margin-top: 12px;
    display: grid; grid-template-columns: auto 1fr auto 1fr;
    gap: 2px 14px; align-items: start;
  }
  .meta-icon { font-size:10px; color:#1a1815; opacity:0.7; line-height:1.7; grid-column:1; grid-row:1/3; padding-top:2px; }
  .meta-item { font-size:clamp(7px,0.75vw,9px); font-weight:500; letter-spacing:0.16em; text-transform:uppercase; color:#1a1815; line-height:1.8; }
  .meta-slash { font-size:clamp(7px,0.75vw,9px); color:rgba(26,24,21,0.40); line-height:1.8; }
  .meta-value { font-size:clamp(7px,0.75vw,9px); font-weight:300; letter-spacing:0.08em; text-transform:uppercase; color:#1a1815; line-height:1.8; }
  .foot-copy { font-size:clamp(7px,0.75vw,9px); letter-spacing:0.12em; color:rgba(26,24,21,0.55); align-self:flex-end; text-align:right; margin-top:12px; }
  .right-foot-row { display:flex; justify-content:space-between; align-items:flex-end; }
</style>
</head>
<body>

__SIDEBAR_HTML__

<div id="main">
<div id="section-terrain">
  <canvas id="terrain"></canvas>
  <div id="header">
    <div class="header-left">
      <div class="series-label">Vector Flow Series</div>
      <div class="rfx-num">RFX<span> #14</span></div>
    </div>
    <div class="header-divider"></div>
    <div class="header-center">
      <div class="header-title">Terrain Topology → Procedural Grid<br>through Perspective Projection</div>
      <div class="header-link">vector-flow.io</div>
    </div>
    <div class="header-right">
      <svg class="icon-circle" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
        <circle cx="14" cy="14" r="12" stroke="#1a1815" stroke-width="1.4"/>
        <circle cx="14" cy="14" r="6.5" stroke="#1a1815" stroke-width="1.0"/>
        <circle cx="14" cy="14" r="2" fill="#1a1815"/>
      </svg>
      <div class="copyright">©2025</div>
    </div>
  </div>
  <div class="text-area">
    <div class="col">
      <div class="col-title">The Open Standard</div>
      <div class="foot">
        <span class="foot-label">Palantir<br>Technologies</span>
        <div class="foot-rule"></div>
        <span class="foot-label">Palantir.com</span>
      </div>
    </div>
    <div class="col-divider"></div>
    <div class="col">
      <div class="col-title heavy">Drones on the<br>Battlefield</div>
      <div class="right-foot-row">
        <div class="meta">
          <span class="meta-icon">▽</span>
          <span class="meta-item">Palantir</span>
          <span class="meta-slash">/</span>
          <span class="meta-value">Palo Alto, CA</span>
          <span class="meta-item">Est. 2003</span>
          <span class="meta-slash">/</span>
          <span class="meta-value">HQ Denver, CO</span>
        </div>
        <span class="foot-copy">©2023</span>
      </div>
    </div>
  </div>
</div>
<div id="section-links">
  <div class="ql-page-title">Quick Links</div>
</div>
</div>

<script>
__SIDEBAR_JS__
(function () {
  const canvas = document.getElementById('terrain');
  const ctx    = canvas.getContext('2d');
  const DPR    = window.devicePixelRatio || 1;

  function setup() {
    const W = canvas.clientWidth;
    const H = canvas.clientHeight;
    canvas.width  = W * DPR;
    canvas.height = H * DPR;
    if (animId) cancelAnimationFrame(animId);
    loop(W * DPR, H * DPR);
  }

  function noise(x, z) {
    return (
      Math.sin(x * 0.18 + z * 0.22) * 0.45 +
      Math.sin(x * 0.38 - z * 0.15) * 0.22 +
      Math.sin(x * 0.09 + z * 0.42) * 0.28 +
      Math.cos(x * 0.55 + z * 0.10) * 0.14 +
      Math.sin(x * 0.72 - z * 0.31) * 0.08 +
      Math.cos(x * 0.28 + z * 0.68) * 0.06 +
      Math.abs(Math.sin(x * 0.12 + 0.9)) * 0.32 +
      Math.abs(Math.sin(x * 0.07 - 0.4)) * 0.20
    );
  }

  let offset = 0;
  let animId  = null;

  function loop(W, H) {
    offset += 0.012;
    render(W, H, offset);
    animId = requestAnimationFrame(() => loop(W, H));
  }

  function render(W, H, off) {
    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = '#e8e4d8';
    ctx.fillRect(0, 0, W, H);

    const COLS = 90, ROWS = 52;
    const headerH = 102;
    const availH  = H - headerH;
    const CAM_Y   = headerH + availH * 0.58;
    const FOV = H * 2.6, CAM_Z = -ROWS * 0.42;
    const SPREAD = W * 1.05;
    const H_BACK = availH * 0.38, H_FRONT = availH * 0.10;

    const pts = [];
    for (let row = 0; row <= ROWS; row++) {
      pts[row] = [];
      const t = row / ROWS, wz = CAM_Z + t * ROWS;
      for (let col = 0; col <= COLS; col++) {
        const wx = (col / COLS - 0.5) * SPREAD;
        const hn = noise(col * 0.7, row * 0.55 + off);
        const wy = hn * (H_BACK * (1 - t) + H_FRONT * t);
        const dz = wz - CAM_Z + FOV, sc = FOV / dz;
        pts[row][col] = { sx: W/2 + wx * sc, sy: CAM_Y - wy * sc, t };
      }
    }

    ctx.lineJoin = 'round'; ctx.lineCap = 'round';
    const lc = t => { const a = 0.04 + t*0.60, l = Math.round(55+(1-t)*120); return `rgba(${l},${l},${l-4},${a.toFixed(3)})`; };
    const lw = t => (0.3 + t * 0.85) * DPR;

    for (let row = 0; row <= ROWS; row++) {
      const t = row / ROWS;
      ctx.beginPath();
      ctx.moveTo(pts[row][0].sx, pts[row][0].sy);
      for (let col = 1; col <= COLS; col++) ctx.lineTo(pts[row][col].sx, pts[row][col].sy);
      ctx.strokeStyle = lc(t); ctx.lineWidth = lw(t); ctx.stroke();
    }
    for (let col = 0; col <= COLS; col++) {
      ctx.beginPath();
      ctx.moveTo(pts[0][col].sx, pts[0][col].sy);
      for (let row = 1; row <= ROWS; row++) ctx.lineTo(pts[row][col].sx, pts[row][col].sy);
      ctx.strokeStyle = lc(0.36); ctx.lineWidth = lw(0.4) * 0.85; ctx.stroke();
    }

    // solid cover over header — no lines bleed through
    ctx.fillStyle = '#e8e4d8';
    ctx.fillRect(0, 0, W, headerH);
    // soft fade just below header edge
    const gT = ctx.createLinearGradient(0, headerH, 0, headerH + availH * 0.12);
    gT.addColorStop(0, '#e8e4d8'); gT.addColorStop(1, 'rgba(232,228,216,0)');
    ctx.fillStyle = gT; ctx.fillRect(0, headerH, W, availH * 0.12);

    const gB = ctx.createLinearGradient(0, H*0.82, 0, H);
    gB.addColorStop(0, 'rgba(232,228,216,0)'); gB.addColorStop(1, '#e8e4d8');
    ctx.fillStyle = gB; ctx.fillRect(0, H*0.82, W, H*0.18);
  }

  setup();
  window.addEventListener('resize', setup);
})();

// ── Quick Links ────────────────────────────────────────────────
(async function () {
  const ICONS = {
    folder:   '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><path d="M1 4h4l1.5 1.5H13V12H1V4z"/></svg>',
    file:     '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><path d="M3 1h6l3 3v9H3V1z"/><path d="M9 1v3h3"/></svg>',
    settings: '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><circle cx="7" cy="7" r="2"/><path d="M7 1v2M7 11v2M1 7h2M11 7h2M3.1 3.1l1.4 1.4M9.5 9.5l1.4 1.4M10.9 3.1l-1.4 1.4M4.5 9.5l-1.4 1.4"/></svg>',
    export:   '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><path d="M6 2H2v10h10V8"/><path d="M8 1h5v5"/><line x1="13" y1="1" x2="7" y2="7"/></svg>',
    data:     '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><line x1="1" y1="4" x2="13" y2="4"/><line x1="1" y1="7" x2="13" y2="7"/><line x1="1" y1="10" x2="13" y2="10"/></svg>',
    report:   '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><rect x="2" y="1" width="10" height="12" rx="1"/><line x1="5" y1="5" x2="9" y2="5"/><line x1="5" y1="7.5" x2="9" y2="7.5"/><line x1="5" y1="10" x2="7.5" y2="10"/></svg>',
    arrow:    '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><line x1="1" y1="7" x2="13" y2="7"/><polyline points="8,3 13,7 8,11"/></svg>',
    link:     '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><path d="M5.5 8.5a3 3 0 004.2 0l2-2a3 3 0 00-4.2-4.2L6 3.8"/><path d="M8.5 5.5a3 3 0 00-4.2 0l-2 2a3 3 0 004.2 4.2L8 10.2"/></svg>',
    box:      '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><polyline points="1,4.5 7,8 13,4.5"/><path d="M1 4.5V11h12V4.5L7 1 1 4.5z"/></svg>',
    grid:     '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><rect x="1" y="1" width="5" height="5"/><rect x="8" y="1" width="5" height="5"/><rect x="1" y="8" width="5" height="5"/><rect x="8" y="8" width="5" height="5"/></svg>',
  };
  const ICON_KEYS = Object.keys(ICONS);
  const PENCIL = '<svg viewBox="0 0 14 14" fill="none" stroke-width="1.25"><path d="M9.5 1.5l3 3L4 13H1v-3L9.5 1.5z"/><line x1="7.5" y1="3.5" x2="10.5" y2="6.5"/></svg>';
  const GRIP   = '<svg class="ql-grip" viewBox="0 0 8 14" fill="currentColor" width="8" height="14"><circle cx="2" cy="2.5" r="1"/><circle cx="6" cy="2.5" r="1"/><circle cx="2" cy="7" r="1"/><circle cx="6" cy="7" r="1"/><circle cx="2" cy="11.5" r="1"/><circle cx="6" cy="11.5" r="1"/></svg>';
  const GRIP_CAT = '<svg class="ql-cat-grip" viewBox="0 0 8 14" fill="currentColor" width="8" height="14"><circle cx="2" cy="2.5" r="1"/><circle cx="6" cy="2.5" r="1"/><circle cx="2" cy="7" r="1"/><circle cx="6" cy="7" r="1"/><circle cx="2" cy="11.5" r="1"/><circle cx="6" cy="11.5" r="1"/></svg>';

  const container = document.getElementById('section-links');
  let qlData = [];

  async function openPath(path, btn) {
    btn.disabled = true;
    const origB = btn.style.borderColor, origBg = btn.style.background;
    try {
      const res  = await fetch('/api/open_path', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path})});
      const json = await res.json();
      const ok   = res.ok && json.ok;
      btn.style.borderColor = ok ? 'rgba(26,140,70,0.7)' : 'rgba(200,60,40,0.7)';
      btn.style.background  = ok ? 'rgba(26,140,70,0.08)' : 'rgba(200,60,40,0.08)';
      if (!ok) console.error('[open_path]', json.error, path);
    } catch(e) {
      btn.style.borderColor = 'rgba(200,60,40,0.7)';
      btn.style.background  = 'rgba(200,60,40,0.08)';
      console.error('[open_path]', e);
    }
    setTimeout(() => { btn.style.borderColor = origB; btn.style.background = origBg; btn.disabled = false; }, 1200);
  }

  async function saveData(data) {
    try {
      const res = await fetch('/api/quicklinks', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data)});
      return res.ok;
    } catch(e) { return false; }
  }

  function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;'); }

  function renderView() {
    let html = `<div class="ql-header-row"><div class="ql-page-title">Quick Links</div><button class="ql-edit-toggle" id="ql-edit-toggle">${PENCIL}Bearbeiten</button></div>`;
    html += `<div class="ql-search-wrap"><input class="ql-search-input" id="ql-search" placeholder="Suchen…" autocomplete="off" spellcheck="false"><button class="ql-search-clear" id="ql-search-clear">Zurücksetzen</button></div>`;
    html += `<div class="ql-no-results" id="ql-no-results">Keine Ergebnisse</div>`;
    if (!qlData.length) {
      html += '<div class="ql-empty">Keine Links — Bearbeiten klicken</div>';
    } else {
      for (const cat of qlData) {
        html += `<div class="ql-category"><div class="ql-category-label">${esc(cat.category)}</div><div class="ql-items">`;
        for (const item of (cat.items||[])) {
          html += `<button class="ql-btn" data-ql-path="${esc(item.path)}" data-ql-label="${esc(item.label).toLowerCase()}">${ICONS[item.icon]||ICONS.arrow}${esc(item.label)}</button>`;
        }
        html += '</div></div>';
      }
    }
    container.innerHTML = html;

    const searchEl = container.querySelector('#ql-search');
    const clearEl  = container.querySelector('#ql-search-clear');
    const noRes    = container.querySelector('#ql-no-results');

    function applySearch() {
      const q = searchEl.value.trim().toLowerCase();
      clearEl.classList.toggle('ql-vis', q.length > 0);
      let anyVisible = false;
      container.querySelectorAll('.ql-category').forEach(catEl => {
        const btns = catEl.querySelectorAll('.ql-btn');
        if (!q) { catEl.style.display = ''; btns.forEach(b => b.style.display = ''); anyVisible = true; return; }
        let match = false;
        btns.forEach(b => {
          const hit = b.dataset.qlLabel.includes(q);
          b.style.display = hit ? '' : 'none';
          if (hit) match = true;
        });
        catEl.style.display = match ? '' : 'none';
        if (match) anyVisible = true;
      });
      noRes.classList.toggle('ql-vis', q.length > 0 && !anyVisible);
    }

    searchEl.addEventListener('input', applySearch);
    clearEl.addEventListener('click', () => { searchEl.value = ''; applySearch(); searchEl.focus(); });

    container.onclick = e => {
      const btn = e.target.closest('.ql-btn');
      if (btn) { openPath(btn.dataset.qlPath, btn); return; }
      if (e.target.closest('#ql-edit-toggle')) renderEdit();
    };
  }

  function renderEdit() {
    const draft = JSON.parse(JSON.stringify(qlData));

    function draw() {
      let html = `<div class="ql-header-row"><div class="ql-page-title">Quick Links — Bearbeiten</div><div class="ql-action-row"><button class="ql-cancel-btn" id="ql-cancel">Abbrechen</button><button class="ql-save-btn" id="ql-save">Speichern</button></div></div>`;
      draft.forEach((cat, ci) => {
        html += `<div class="ql-category" draggable="true" data-type="cat" data-ci="${ci}"><div class="ql-cat-header">${GRIP_CAT}<input class="ql-cat-name-input" data-ci="${ci}" value="${esc(cat.category)}" placeholder="Kategoriename"><button class="ql-del-cat" data-ci="${ci}">Löschen</button></div><div class="ql-items">`;
        (cat.items||[]).forEach((item, ii) => {
          const opts = ICON_KEYS.map(k => `<option value="${k}"${k===item.icon?' selected':''}>${k}</option>`).join('');
          html += `<div class="ql-item-row" draggable="true" data-type="item" data-ci="${ci}" data-ii="${ii}">${GRIP}<select class="ql-icon-sel" data-ci="${ci}" data-ii="${ii}">${opts}</select><input class="ql-item-input ql-label-inp" data-ci="${ci}" data-ii="${ii}" data-f="label" value="${esc(item.label)}" placeholder="Label"><input class="ql-item-input ql-path-inp" data-ci="${ci}" data-ii="${ii}" data-f="path" value="${esc(item.path)}" placeholder="Pfad…"><button class="ql-del-item" data-ci="${ci}" data-ii="${ii}">×</button></div>`;
        });
        html += `</div><button class="ql-add-item" data-ci="${ci}">+ Link</button></div>`;
      });
      html += `<button class="ql-add-cat" id="ql-add-cat">+ Kategorie</button>`;
      container.innerHTML = html;

      // sync inputs → draft
      container.querySelectorAll('.ql-cat-name-input').forEach(el => el.oninput = () => { draft[+el.dataset.ci].category = el.value; });
      container.querySelectorAll('.ql-icon-sel').forEach(el => el.onchange = () => { draft[+el.dataset.ci].items[+el.dataset.ii].icon = el.value; });
      container.querySelectorAll('.ql-item-input').forEach(el => el.oninput = () => { draft[+el.dataset.ci].items[+el.dataset.ii][el.dataset.f] = el.value; });

      container.querySelector('#ql-cancel').onclick = renderView;
      container.querySelector('#ql-save').onclick = async () => {
        const ok = await saveData(draft);
        if (ok) { qlData = JSON.parse(JSON.stringify(draft)); renderView(); }
        else console.error('[quicklinks] save failed');
      };
      container.querySelectorAll('.ql-del-item').forEach(b => b.onclick = () => { draft[+b.dataset.ci].items.splice(+b.dataset.ii, 1); draw(); });
      container.querySelectorAll('.ql-del-cat').forEach(b => b.onclick = () => { draft.splice(+b.dataset.ci, 1); draw(); });
      container.querySelectorAll('.ql-add-item').forEach(b => b.onclick = () => {
        draft[+b.dataset.ci].items.push({label:'', path:'', icon:'arrow'}); draw();
        const rows = container.querySelectorAll(`.ql-item-row[data-ci="${b.dataset.ci}"]`);
        if (rows.length) rows[rows.length-1].querySelector('.ql-label-inp').focus();
      });
      container.querySelector('#ql-add-cat').onclick = () => {
        draft.push({category:'', items:[]}); draw();
        const ins = container.querySelectorAll('.ql-cat-name-input');
        if (ins.length) ins[ins.length-1].focus();
      };

      // ── Drag to reorder ──
      let dg = null;
      function cleanDI() { container.querySelectorAll('.ql-drop-before,.ql-drop-after').forEach(e => e.classList.remove('ql-drop-before','ql-drop-after')); }

      function attachDrag(els, onDrop) {
        els.forEach(el => {
          el.addEventListener('dragstart', e => {
            dg = el; e.dataTransfer.effectAllowed = 'move';
            requestAnimationFrame(() => { el.style.opacity = '0.30'; });
            // prevent child input drag confusion
            e.stopPropagation();
          });
          el.addEventListener('dragend', () => { el.style.opacity = ''; dg = null; cleanDI(); });
          el.addEventListener('dragover', e => {
            if (!dg || dg === el || dg.dataset.type !== el.dataset.type) return;
            if (dg.dataset.type === 'item' && dg.dataset.ci !== el.dataset.ci) return;
            e.preventDefault(); e.stopPropagation();
            cleanDI();
            const mid = el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2;
            el.classList.add(e.clientY < mid ? 'ql-drop-before' : 'ql-drop-after');
          });
          el.addEventListener('dragleave', cleanDI);
          el.addEventListener('drop', e => {
            e.preventDefault(); e.stopPropagation();
            if (!dg || dg === el || dg.dataset.type !== el.dataset.type) { cleanDI(); return; }
            if (dg.dataset.type === 'item' && dg.dataset.ci !== el.dataset.ci) { cleanDI(); return; }
            onDrop(dg, el, e); cleanDI();
          });
        });
      }

      function reorder(list, from, to, clientY, el) {
        const mid = el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2;
        let ins = clientY < mid ? to : to + 1;
        if (ins > from) ins--;
        const [m] = list.splice(from, 1);
        list.splice(ins, 0, m);
        draw();
      }

      attachDrag(Array.from(container.querySelectorAll('.ql-category[draggable]')), (f, t, e) => {
        reorder(draft, +f.dataset.ci, +t.dataset.ci, e.clientY, t);
      });
      attachDrag(Array.from(container.querySelectorAll('.ql-item-row[draggable]')), (f, t, e) => {
        reorder(draft[+f.dataset.ci].items, +f.dataset.ii, +t.dataset.ii, e.clientY, t);
      });
    }
    draw();
  }

  // init
  try {
    const res = await fetch('/api/quicklinks');
    qlData = res.ok ? await res.json() : [];
  } catch(e) { qlData = []; }
  renderView();
})();
</script>
</body>
</html>"""

TERRAIN_HTML = (TERRAIN_HTML
    .replace('__SIDEBAR_CSS__', SIDEBAR_CSS)
    .replace('__SIDEBAR_JS__', SIDEBAR_JS)
    .replace('__SIDEBAR_HTML__', sidebar_html('terrain'))
)


@app.route('/terrain')
def terrain():
    return TERRAIN_HTML


_QUICKLINKS_PATH = os.path.join(_SETTINGS_DIR, 'quicklinks.json')
_MAPPINGS_PATH        = os.path.join(_SETTINGS_DIR, 'engine_mappings.json')
_STEP_TEMPLATES_PATH  = os.path.join(_SETTINGS_DIR, 'step_templates.json')


def _load_step_templates():
    try:
        with open(_STEP_TEMPLATES_PATH, 'r', encoding='utf-8') as f:
            return json.load(f)
    except FileNotFoundError:
        return []


@app.route('/api/quicklinks', methods=['GET'])
def api_quicklinks():
    try:
        with open(_QUICKLINKS_PATH, 'r', encoding='utf-8') as f:
            data = json.load(f)
    except FileNotFoundError:
        data = []
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    return jsonify(data)


@app.route('/api/quicklinks', methods=['POST'])
def api_quicklinks_save():
    try:
        data = request.json
        with open(_QUICKLINKS_PATH, 'w', encoding='utf-8') as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    return jsonify({'ok': True})


@app.route('/api/engine-mappings', methods=['GET'])
def api_engine_mappings_get():
    try:
        with open(_MAPPINGS_PATH, 'r', encoding='utf-8') as f:
            data = json.load(f)
    except FileNotFoundError:
        data = []
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    return jsonify(data)


@app.route('/api/engine-mappings', methods=['POST'])
def api_engine_mappings_save():
    try:
        data = request.json or []
        with open(_MAPPINGS_PATH, 'w', encoding='utf-8') as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    return jsonify({'ok': True, 'count': len(data)})


@app.route('/api/step-templates', methods=['GET'])
def api_step_templates_get():
    try:
        return jsonify(_load_step_templates())
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/api/step-templates', methods=['POST'])
def api_step_templates_save():
    try:
        data = request.json or []
        with open(_STEP_TEMPLATES_PATH, 'w', encoding='utf-8') as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
        return jsonify({'ok': True, 'count': len(data)})
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/api/hb/templates')
def api_template_stepss():
    try:
        import httpx
        params = {"testcell": FLOW_CONFIG.get("HB_TESTBED", "")}
        if request.args.get("engine_name"):
            params["engine_name"] = request.args.get("engine_name")
        with httpx.Client(timeout=30) as c:
            r = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-templates",
                      params=params, headers=_hb_auth_headers())
            r.raise_for_status()
            data = r.json()
        templates = data if isinstance(data, list) else (
            data.get('templates') or data.get('items') or data.get('results') or [])
        return jsonify(templates)
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/hb/boost-procedures')
def api_hb_boost_procedures():
    order_id = request.args.get("test_order_id", "").strip()
    if not order_id:
        return jsonify({"error": "test_order_id required"}), 400
    try:
        import httpx
        with httpx.Client(timeout=30) as c:
            r = c.get(f"{FLOW_CONFIG['HB_BASE']}/test-order/testProcedures",
                      params={"test_order_id": order_id, "include_steps": True,
                              "include_instructions": True, "exclude_actions": True},
                      headers=_hb_auth_headers())
            r.raise_for_status()
            raw = r.json()
        raw_procs = raw if isinstance(raw, list) else (
            raw.get("procedures") or raw.get("testProcedures") or
            raw.get("items") or raw.get("results") or [])
        normalized = []
        for p in raw_procs:
            raw_steps = p.get("testSteps") or p.get("steps") or []
            steps = [{"id": s.get("id", ""),
                      "title": s.get("title") or s.get("name") or "",
                      "type": (s.get("testStepType") or {}).get("name")
                               if isinstance(s.get("testStepType"), dict)
                               else (s.get("stepType") or "6.0 Standard Procedures"),
                      "description": s.get("description") or ""}
                     for s in raw_steps]
            normalized.append({
                "id":                   p.get("id", ""),
                "name":                 p.get("name") or p.get("identifier") or "",
                "title":                p.get("title") or p.get("name") or "",
                "procedure_type_name":  (p.get("testProcedureType") or {}).get("name")
                                        if isinstance(p.get("testProcedureType"), dict)
                                        else (p.get("testProcedureType") or "6.0 STANDARD PROCEDURES"),
                "description":          p.get("description") or "",
                "steps":                steps,
            })
        return jsonify({"procedures": normalized, "count": len(normalized)})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/open_path', methods=['POST'])
def api_open_path():
    path = (request.json or {}).get('path', '').strip()
    if not path:
        return jsonify({'ok': False, 'error': 'no path'}), 400
    try:
        import subprocess, sys
        if sys.platform == 'win32':
            os.startfile(path)
        elif sys.platform == 'darwin':
            subprocess.Popen(['open', path])
        else:
            subprocess.Popen(['xdg-open', path])
    except Exception as e:
        return jsonify({'ok': False, 'error': str(e)}), 500
    return jsonify({'ok': True})


# ══════════════════════════════════════════════════════════════════
#  FLOW PAGE  (/flow)
# ══════════════════════════════════════════════════════════════════

FLOW_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RFX — Flow Editor</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  :root {
    --sb-bg: rgba(35,35,35,0.97);
    --sb-border: rgba(255,255,255,0.15);
    --sb-label: rgba(255,255,255,0.45);
    --sb-accent: #ffffff;
    --sb-hover: rgba(255,255,255,0.07);
    --sb-hover-border: rgba(255,255,255,0.30);
    --sb-active-bg: rgba(255,255,255,0.10);
    --sb-icon: rgba(255,255,255,0.50);
    --sb-num: rgba(255,255,255,0.30);
  }

  __SIDEBAR_CSS__

  body {
    background: #2d2d2d;
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    overflow: hidden;
    height: 100vh;
    width: 100vw;
    display: flex;
    flex-direction: row;
  }

  #main {
    flex: 1;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  #header {
    padding: 22px 36px 18px;
    display: flex;
    align-items: flex-start;
    border-bottom: 1px solid rgba(255,255,255,0.22);
    flex-shrink: 0;
    z-index: 10;
  }

  .header-left { display:flex; flex-direction:column; gap:4px; min-width:200px; }
  .series-label {
    font-size: 10px; letter-spacing: 0.14em;
    color: rgba(255,255,255,0.70); text-transform: uppercase; font-weight: 400;
  }
  .rfx-num {
    font-size: 64px; font-weight: 700; color: #ffffff;
    line-height: 0.88; letter-spacing: -0.03em;
  }
  .rfx-num span {
    font-size: 22px; font-weight: 300; vertical-align: top;
    padding-top: 8px; display: inline-block; letter-spacing: 0.01em;
  }
  .divider {
    width: 1px; background: rgba(255,255,255,0.22);
    margin: 0 32px; align-self: stretch;
  }
  .header-center { display:flex; flex-direction:column; justify-content:center; gap:10px; padding:4px 0; }
  .header-title {
    font-size: 20px; font-weight: 400; color: #ffffff;
    line-height: 1.25; letter-spacing: -0.01em; max-width: 420px;
  }
  .header-link {
    display: flex; align-items: center; gap: 8px;
    font-size: 10.5px; letter-spacing: 0.10em;
    color: rgba(255,255,255,0.65); text-transform: uppercase;
  }
  .header-link::before { content: '→'; font-size: 12px; }
  .header-right {
    margin-left: auto; display: flex; flex-direction: column;
    align-items: flex-end; justify-content: space-between; padding: 4px 0; gap: 24px;
  }
  .icon-circle { width: 28px; height: 28px; }
  .copyright { font-size: 10px; letter-spacing: 0.08em; color: rgba(255,255,255,0.50); }

  /* ── Palette sidebar ── */
  #palette {
    display: flex;
    flex-direction: column;
    width: 148px;
    flex-shrink: 0;
    background: rgba(255,255,255,0.02);
    border-right: 1px solid rgba(255,255,255,0.12);
    overflow: hidden;
  }
  #palette-nodes {
    flex: 1;
    overflow-y: auto;
    padding: 10px 8px 6px;
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
  #palette-nodes::-webkit-scrollbar { width: 4px; }
  #palette-nodes::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
  #palette-actions {
    padding: 8px;
    border-top: 1px solid rgba(255,255,255,0.08);
    display: flex;
    flex-direction: column;
    gap: 5px;
    flex-shrink: 0;
  }
  .palette-label {
    font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
    color: rgba(255,255,255,0.35); margin: 6px 4px 2px; white-space: nowrap;
  }
  .palette-item {
    display: flex; align-items: center; gap: 6px;
    padding: 5px 10px;
    border: 1px solid;
    border-radius: 4px;
    font-size: 11px; letter-spacing: 0.04em;
    cursor: grab; user-select: none;
    transition: opacity .15s, transform .15s;
    white-space: nowrap;
    width: 100%;
  }
  .palette-item:hover { opacity: 0.85; transform: translateX(2px); }
  .palette-item:active { cursor: grabbing; }
  .palette-sep { height: 1px; background: rgba(255,255,255,0.12); margin: 4px 0; }
  .palette-hint { display: none; }

  /* ── Canvas area ── */
  #canvas-area {
    flex: 1;
    position: relative;
    overflow: hidden;
    background-image: radial-gradient(circle, rgba(255,255,255,0.22) 1.2px, transparent 1.2px);
    background-size: 24px 24px;
  }
  #canvas-area.drag-over { background-color: rgba(255,255,255,0.03); }

  /* ── SVG layer ── */
  #svg-layer {
    position: absolute;
    inset: 0;
    width: 100%; height: 100%;
    pointer-events: none;
    z-index: 5;
  }
  .conn-path {
    fill: none;
    stroke-width: 2;
    stroke-linecap: round;
    cursor: pointer;
    transition: stroke-width .15s;
  }
  .conn-path:hover { stroke-width: 3.5; }
  #temp-path {
    fill: none;
    stroke: rgba(255,255,255,0.55);
    stroke-width: 2;
    stroke-dasharray: 6 4;
    stroke-linecap: round;
    pointer-events: none;
  }

  /* ── Nodes ── */
  .node {
    position: absolute;
    min-width: 148px;
    background: #3a3a3a;
    border: 1px solid rgba(255,255,255,0.10);
    border-radius: 6px;
    z-index: 10;
    user-select: none;
    transition: box-shadow .15s;
  }
  .node:hover { box-shadow: 0 4px 24px rgba(0,0,0,0.55); }
  .node.selected { border-color: rgba(255,255,255,0.6); box-shadow: 0 0 0 2px rgba(255,255,255,0.2); }
  .node-header {
    display: flex; align-items: center; gap: 7px;
    padding: 7px 10px;
    border-radius: 5px 5px 0 0;
    cursor: move;
    border-bottom: 1px solid rgba(255,255,255,0.07);
  }
  .node-icon { font-size: 13px; }
  .node-title { font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: #fff; }
  .node-body { padding: 8px 0; display: flex; flex-direction: column; gap: 2px; }
  .node-row {
    display: flex; align-items: center; justify-content: space-between;
    padding: 3px 0; min-height: 24px;
  }
  .port-wrap { display: flex; align-items: center; gap: 6px; padding: 0 10px; }
  .port-wrap.out { flex-direction: row-reverse; }
  .port {
    width: 10px; height: 10px; border-radius: 50%;
    border: 2px solid; background: #3a3a3a;
    cursor: crosshair; flex-shrink: 0;
    transition: transform .12s, background .12s;
    position: relative; z-index: 15;
  }
  .port:hover { transform: scale(1.4); }
  .port.connected { background: currentColor; }
  .port-label { font-size: 9.5px; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255,255,255,0.38); }
  .node-delete {
    position: absolute; top: 6px; right: 8px;
    font-size: 13px; color: rgba(255,255,255,0.22);
    cursor: pointer; line-height: 1; display: none;
  }
  .node:hover .node-delete { display: block; }
  .node-delete:hover { color: rgba(255,80,80,0.85); }

  /* ── Group node ── */
  .node.group-node {
    z-index: 2;
    min-width: 200px;
    min-height: 160px;
    border-radius: 10px;
    border-width: 2px;
    background: transparent;
    box-shadow: none;
  }
  .node.group-node:hover { box-shadow: none; }
  .group-label {
    position: absolute;
    top: 0; left: 0; right: 0;
    padding: 7px 12px;
    font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
    border-radius: 8px 8px 0 0;
    pointer-events: none;
  }
  .group-resize {
    position: absolute;
    bottom: 4px; right: 4px;
    width: 14px; height: 14px;
    cursor: se-resize;
    opacity: 0.5;
    font-size: 10px; line-height: 14px; text-align: center;
  }
  .group-resize:hover { opacity: 1; }
  .group-collapse-btn {
    position: absolute; top: 6px; right: 28px;
    font-size: 11px; cursor: pointer; opacity: 0.6; user-select: none;
  }
  .group-collapse-btn:hover { opacity: 1; }
  .group-badge {
    width: 7px; height: 7px; border-radius: 50%;
    display: inline-block; margin-left: 5px; flex-shrink: 0;
    vertical-align: middle;
  }

  /* ── Note node ── */
  .node.note-node { background: #3d3a1a; border-color: rgba(250,204,21,0.35); min-width: 160px; }
  .note-preview {
    padding: 6px 10px 8px;
    font-size: 10.5px; color: rgba(250,204,21,0.75);
    white-space: pre-wrap; word-break: break-word;
    line-height: 1.45; max-height: 120px; overflow: hidden;
    font-style: italic;
  }

  /* ── Code node editor ── */
  .cp-textarea.code-editor {
    font-family: 'Fira Mono', 'Consolas', monospace;
    font-size: 11px; line-height: 1.5;
    min-height: 180px; resize: vertical;
    background: rgba(0,0,0,0.3); color: #e2e8f0;
  }
  .code-hints {
    font-size: 9px; color: rgba(255,255,255,0.3);
    letter-spacing: 0.04em; line-height: 1.7;
    padding: 2px 0 6px; font-family: monospace;
  }

  /* ── Status dot ── */
  .node-status {
    width: 6px; height: 6px; border-radius: 50%;
    background: rgba(255,255,255,0.12);
    flex-shrink: 0; margin-left: auto; margin-right: 2px;
    transition: background .2s;
  }
  .node-status.ok      { background: #22c55e; }
  .node-status.partial { background: #f59e0b; }

  /* ── Work area (vertical: main-row + output-drawer) ── */
  #work-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
  #main-row  { flex: 1; display: flex; flex-direction: row; overflow: hidden; min-height: 0; }

  /* ── Output Drawer ── */
  #output-drawer {
    height: 0; overflow: hidden; flex-shrink: 0;
    background: #2d2d2d;
    border-top: 1px solid rgba(255,255,255,0.2);
    transition: height .25s cubic-bezier(.4,0,.2,1);
    display: flex; flex-direction: column;
  }
  #output-drawer.open    { height: 260px; }
  #output-drawer.resizing { transition: none; }
  #od-resize-handle {
    height: 5px; flex-shrink: 0; cursor: ns-resize;
    background: transparent;
  }
  #od-resize-handle:hover, #od-resize-handle.dragging {
    background: rgba(255,255,255,0.35);
  }
  #od-header {
    display: flex; align-items: center;
    padding: 0 16px;
    background: #0a0c10;
    border-bottom: 1px solid rgba(255,255,255,0.12);
    flex-shrink: 0; height: 34px;
  }
  #od-title {
    font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
    color: rgba(255,255,255,0.5); margin-right: 16px; white-space: nowrap;
  }
  #od-tabs { display: flex; align-items: stretch; flex: 1; gap: 0; overflow-x: auto; }
  .od-tab {
    padding: 0 16px; font-size: 10px; cursor: pointer;
    color: rgba(255,255,255,0.4); border-right: 1px solid rgba(255,255,255,0.07);
    display: flex; align-items: center; gap: 6px; white-space: nowrap;
    border-bottom: 2px solid transparent; transition: color .15s;
  }
  .od-tab:hover  { color: rgba(255,255,255,0.75); }
  .od-tab.active { color: #ffffff; border-bottom-color: #ffffff; }
  .od-tab-err { color: #ef4444; }
  #od-close { cursor: pointer; color: rgba(255,255,255,0.25); font-size: 17px; margin-left: 12px; background: none; border: none; }
  #od-close:hover { color: rgba(255,255,255,0.6); }
  #od-body  { flex: 1; display: flex; flex-direction: column; overflow: hidden; padding: 0; }
  .od-pane  { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; padding: 12px 16px; }
  #od-body .cp-output-pre { flex: 1; max-height: none; }

  /* ── Config Panel ── */
  #config-panel {
    width: 0; overflow: hidden; flex-shrink: 0;
    background: #2d2d2d;
    border-left: 1px solid rgba(255,255,255,0.15);
    transition: width .22s cubic-bezier(.4,0,.2,1);
    display: flex; flex-direction: column;
  }
  #config-panel.open { width: 260px; }
  #cp-header {
    padding: 14px 16px 10px;
    border-bottom: 1px solid rgba(255,255,255,0.07);
    display: flex; align-items: center; gap: 8px;
    flex-shrink: 0;
  }
  #cp-icon  { font-size: 15px; }
  #cp-title { font-size: 12px; font-weight: 600; color: #fff; flex: 1; white-space: nowrap; }
  #cp-close { cursor: pointer; color: rgba(255,255,255,0.28); font-size: 17px; line-height: 1; }
  #cp-close:hover { color: rgba(255,255,255,0.7); }
  #cp-body  { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
  .cp-field { display: flex; flex-direction: column; gap: 4px; }
  .cp-label { font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: rgba(255,255,255,0.32); }
  .cp-req   { color: rgba(245,158,11,0.7); margin-left: 2px; }
  .cp-input, .cp-select, .cp-textarea {
    background: #1e1e1e;
    border: 1px solid rgba(255,255,255,0.10);
    border-radius: 3px;
    color: rgba(255,255,255,0.80);
    font-size: 11px; font-family: inherit;
    padding: 5px 8px; outline: none; width: 100%;
    transition: border-color .15s;
    box-sizing: border-box;
  }
  .cp-input:focus, .cp-select:focus, .cp-textarea:focus { border-color: rgba(255,255,255,0.55); }
  .cp-select { appearance: none; cursor: pointer; }
  .cp-textarea { resize: vertical; min-height: 54px; line-height: 1.5; }
  .cp-empty { font-size: 10px; color: rgba(255,255,255,0.22); text-align: center; padding: 24px 0; letter-spacing: 0.08em; }

  /* ── Procedure Builder (custom_steps config panel) ── */
  .pb-wrap { display: flex; flex-direction: column; gap: 8px; }
  .pb-proc {
    background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1);
    border-radius: 5px; overflow: hidden;
  }
  .pb-proc-hdr {
    display: flex; flex-direction: column; gap: 4px;
    padding: 7px 8px; background: rgba(255,255,255,0.05);
    border-bottom: 1px solid rgba(255,255,255,0.07);
  }
  .pb-proc-hdr-top { display: flex; gap: 5px; align-items: center; }
  .pb-proc-num {
    font-size: 8px; letter-spacing: 0.15em; text-transform: uppercase;
    color: rgba(255,255,255,0.28); flex-shrink: 0;
  }
  .pb-proc-body { padding: 7px 8px; display: flex; flex-direction: column; gap: 5px; }
  .pb-proc-title-row { display: flex; gap: 5px; align-items: center; }
  .pb-input {
    flex: 1; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1);
    border-radius: 3px; color: rgba(255,255,255,0.82); padding: 4px 7px;
    font-family: inherit; font-size: 11px; outline: none; min-width: 0;
  }
  .pb-input:focus { border-color: rgba(255,255,255,0.4); }
  .pb-select {
    background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.1);
    border-radius: 3px; color: rgba(255,255,255,0.75); padding: 4px 5px;
    font-family: inherit; font-size: 10px; outline: none; cursor: pointer;
    min-width: 0; max-width: 100%;
  }
  .pb-select:focus { border-color: rgba(255,255,255,0.4); }
  .pb-del {
    background: none; border: none; color: rgba(255,255,255,0.22);
    cursor: pointer; font-size: 12px; padding: 1px 4px; flex-shrink: 0;
    line-height: 1;
  }
  .pb-del:hover { color: #ef4444; }
  .pb-steps-label {
    font-size: 8px; letter-spacing: 0.15em; text-transform: uppercase;
    color: rgba(255,255,255,0.25); padding-bottom: 3px;
    border-bottom: 1px solid rgba(255,255,255,0.06); margin-bottom: 2px;
  }
  .pb-step-row { display: flex; flex-direction: column; gap: 3px; margin-bottom: 5px; }
  .pb-step-row:last-of-type { margin-bottom: 0; }
  .pb-step-bottom { display: flex; gap: 4px; align-items: center; }
  .pb-add-step {
    background: none; border: 1px dashed rgba(255,255,255,0.15);
    border-radius: 3px; color: rgba(255,255,255,0.32); cursor: pointer;
    font-family: inherit; font-size: 10px; padding: 3px 8px;
    width: 100%; text-align: left; margin-top: 3px;
  }
  .pb-add-step:hover { border-color: rgba(255,255,255,0.38); color: rgba(255,255,255,0.65); }
  .pb-add-proc {
    background: none; border: 1px dashed rgba(100,116,139,0.5);
    border-radius: 4px; color: rgba(100,116,139,0.85); cursor: pointer;
    font-family: inherit; font-size: 11px; padding: 5px 10px;
    width: 100%; text-align: center; letter-spacing: 0.05em;
  }
  .pb-add-proc:hover { border-color: #64748b; color: #94a3b8; }

  /* ── Service status ── */
  #svc-status { display:flex; align-items:center; gap:5px; }
  .svc-dot {
    width:7px; height:7px; border-radius:50%;
    background: rgba(255,255,255,0.15);
    transition: background .4s;
  }
  .svc-dot.ok  { background: #22c55e; }
  .svc-dot.err { background: #ef4444; }
  .svc-label { font-size:8px; letter-spacing:0.10em; text-transform:uppercase; color:rgba(255,255,255,0.25); }

  /* ── Toolbar buttons ── */
  .btn-flow {
    display: flex; align-items: center; gap: 5px; width: 100%;
    padding: 5px 11px; border: 1px solid rgba(255,255,255,0.12);
    border-radius: 4px; background: rgba(255,255,255,0.04);
    color: rgba(255,255,255,0.55); font-size: 11px; font-family: inherit;
    letter-spacing: 0.05em; cursor: pointer; transition: background .15s, color .15s;
    white-space: nowrap;
  }
  .btn-flow:hover  { background: rgba(255,255,255,0.09); color: rgba(255,255,255,0.85); }
  .btn-flow.btn-danger:hover { border-color: rgba(239,68,68,0.4); color: rgba(239,68,68,0.8); background: rgba(239,68,68,0.07); }

  /* ══ Modal ════════════════════════════════════════════════════ */
  .flow-modal {
    position: fixed; inset: 0; z-index: 200;
    background: rgba(0,0,0,0.65); backdrop-filter: blur(3px);
    display: flex; align-items: center; justify-content: center;
  }
  .flow-modal.hidden { display: none; }

  .flow-modal-box {
    background: #242424; border: 1px solid rgba(255,255,255,0.16);
    border-radius: 10px; padding: 32px 36px; min-width: 400px;
    display: flex; flex-direction: column; gap: 24px;
    box-shadow: 0 24px 60px rgba(0,0,0,0.55);
    max-height: 92vh; overflow-y: auto;
  }
  .flow-modal-box-wide { min-width: min(880px, 96vw); max-width: 96vw; }

  .flow-modal-title {
    font-size: 20px; font-weight: 700; color: #fff;
    letter-spacing: -0.02em; line-height: 1;
  }

  .flow-modal-input {
    background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.14);
    border-radius: 6px; color: rgba(255,255,255,0.92); font-size: 13px;
    font-family: inherit; padding: 10px 13px;
    outline: none; width: 100%; transition: border-color .15s;
  }
  .flow-modal-input:focus { border-color: rgba(255,255,255,0.50); background: rgba(255,255,255,0.10); }
  .flow-modal-input::placeholder { color: rgba(255,255,255,0.30); }

  .fm-label {
    font-size: 9.5px; letter-spacing: 0.16em; text-transform: uppercase;
    color: rgba(255,255,255,0.45); display: block; margin-bottom: 10px;
  }

  .flow-modal-actions {
    display: flex; align-items: center; justify-content: space-between;
    padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.10);
  }
  .flow-modal-btn {
    padding: 9px 22px; font-size: 11px; font-family: inherit;
    letter-spacing: 0.08em; text-transform: uppercase; border-radius: 6px;
    cursor: pointer; border: 1px solid; transition: all .14s; font-weight: 600;
  }
  .flow-modal-btn.primary {
    background: #fff; border-color: #fff; color: #1a1a1a;
  }
  .flow-modal-btn.primary:hover { background: rgba(255,255,255,0.88); }
  .flow-modal-btn.secondary {
    background: transparent; border-color: rgba(255,255,255,0.20);
    color: rgba(255,255,255,0.55);
  }
  .flow-modal-btn.secondary:hover { border-color: rgba(255,255,255,0.40); color: rgba(255,255,255,0.85); }

  .save-overwrite-warn {
    font-size: 11px; color: #f59e0b;
    display: flex; align-items: center; gap: 6px;
  }
  .save-overwrite-warn.hidden { display: none; }

  /* ── Flow name badge in toolbar ── */
  #flow-badge {
    display: inline-flex; align-items: center; gap: 7px;
    padding: 5px 12px; border-radius: 6px;
    background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.10);
    font-size: 11px; color: rgba(255,255,255,0.55); letter-spacing: 0.04em;
    white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;
    user-select: none;
  }
  #flow-badge.loaded { color: rgba(255,255,255,0.82); border-color: rgba(255,255,255,0.20); }
  #flow-badge.dirty  { font-style: italic; }
  #flow-badge::before {
    content: ''; display: inline-block; width: 6px; height: 6px;
    border-radius: 50%; background: rgba(255,255,255,0.22); flex-shrink: 0;
  }
  #flow-badge.loaded::before { background: #4ade80; }
  #flow-badge.dirty::before  { background: #f59e0b; }

  /* ── Grouped sections in load modal ── */
  .flow-group { display: flex; flex-direction: column; gap: 10px; }
  .flow-group-header {
    font-size: 15px; font-weight: 700; color: rgba(255,255,255,0.90);
    letter-spacing: -0.01em; line-height: 1;
    padding-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.12);
    margin-bottom: 2px;
  }
  .flow-group-header.uncategorized {
    font-size: 12px; font-weight: 500; color: rgba(255,255,255,0.38);
    letter-spacing: 0.06em; text-transform: uppercase;
  }
  #flow-list-wrap { display: flex; flex-direction: column; gap: 24px; overflow-y: auto; max-height: 60vh; }

  /* ── Tag chip input (save modal) ── */
  .tag-chip-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-height: 32px; }
  .tag-chip {
    display: inline-flex; align-items: center; gap: 5px;
    padding: 3px 10px 3px 11px; border-radius: 20px; font-size: 11px;
    background: rgba(255,255,255,0.09); border: 1px solid rgba(255,255,255,0.18);
    color: rgba(255,255,255,0.80);
  }
  .tag-chip-remove {
    background: none; border: none; color: rgba(255,255,255,0.40); cursor: pointer;
    font-size: 13px; padding: 0; line-height: 1; display: flex; align-items: center;
    transition: color .12s;
  }
  .tag-chip-remove:hover { color: rgba(239,68,68,0.80); }
  .tag-chip-add {
    background: none; border: 1px dashed rgba(255,255,255,0.20); border-radius: 20px;
    color: rgba(255,255,255,0.35); font-size: 11px; padding: 3px 10px;
    cursor: pointer; font-family: inherit; transition: all .12s;
  }
  .tag-chip-add:hover { border-color: rgba(255,255,255,0.45); color: rgba(255,255,255,0.75); }
  .tag-chip-input {
    background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.25);
    border-radius: 20px; color: #fff; font-size: 11px; padding: 3px 10px;
    outline: none; font-family: inherit; min-width: 0; width: 110px;
  }

  /* ── Save modal overwrite grid ── */
  .save-grid-wrap { max-height: 70vh; overflow-y: auto; display: flex; flex-direction: column; gap: 24px; }
  .flow-list-empty { font-size: 12px; color: rgba(255,255,255,0.35); text-align: center; padding: 16px 0; }

  /* ── Category pills ── */
  .cat-pills { display: flex; flex-wrap: wrap; gap: 5px; }
  .cat-pill {
    padding: 4px 10px; font-size: 10.5px; font-weight: 500;
    cursor: pointer; border: 1px solid rgba(255,255,255,0.18);
    border-radius: 4px; color: rgba(255,255,255,0.60);
    background: transparent; transition: all .13s;
    white-space: nowrap; font-family: inherit;
    letter-spacing: 0.03em;
  }
  .cat-pill:hover { border-color: rgba(255,255,255,0.45); color: rgba(255,255,255,0.90); background: rgba(255,255,255,0.06); }
  .cat-pill.selected { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.60); color: #fff; font-weight: 600; }
  .cat-pill.custom-new { border-style: dashed; }
  #save-custom-tag-wrap { display: none; margin-top: 8px; }
  #save-custom-tag-wrap.visible { display: flex; gap: 8px; align-items: center; }

  /* ── Tag chips (in cards) ── */
  .flow-tag {
    display: inline-flex; align-items: center; gap: 3px;
    padding: 2px 9px; font-size: 10px; font-weight: 500;
    border-radius: 20px; white-space: nowrap; cursor: default;
    border: 1px solid rgba(255,255,255,0.18); color: rgba(255,255,255,0.70);
    background: rgba(255,255,255,0.07);
  }

  /* ── Category management panel ── */
  .cat-mgmt-panel {
    background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.10);
    border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px;
  }
  .cat-mgmt-panel.hidden { display: none; }
  .cat-mgmt-list { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto; }
  .cat-mgmt-row {
    display: flex; align-items: center; gap: 6px; padding: 5px 6px;
    border-radius: 6px; transition: background .1s;
  }
  .cat-mgmt-row:hover { background: rgba(255,255,255,0.05); }
  .cat-mgmt-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
  .cat-mgmt-name { flex: 1; font-size: 12px; color: rgba(255,255,255,0.80); }
  .cat-mgmt-input {
    flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.25);
    border-radius: 5px; color: #fff; font-size: 12px; padding: 3px 8px;
    outline: none; font-family: inherit;
  }
  .cat-mgmt-add { display: flex; gap: 8px; align-items: center; padding-top: 4px; border-top: 1px solid rgba(255,255,255,0.08); }

  /* ── Card inline edit (tags) ── */
  .fc-edit-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
  .fc-tag-pill {
    padding: 3px 10px; font-size: 10.5px; border-radius: 20px;
    border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.50);
    background: transparent; cursor: pointer; font-family: inherit; transition: all .12s;
  }
  .fc-tag-pill.selected { color: #fff; font-weight: 600; }

  /* ── Load toolbar ── */
  .flow-load-toolbar { display: flex; flex-direction: column; gap: 10px; }
  #load-tag-filter { display: flex; flex-wrap: wrap; gap: 5px; }
  .flow-tag-filter {
    padding: 4px 12px; font-size: 10.5px; border-radius: 20px;
    cursor: pointer; border: 1px solid rgba(255,255,255,0.16);
    color: rgba(255,255,255,0.55); transition: all .12s;
    white-space: nowrap; font-family: inherit; background: transparent;
  }
  .flow-tag-filter:hover { border-color: rgba(255,255,255,0.40); color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.05); }
  .flow-tag-filter.active { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.55); color: #fff; font-weight: 600; }

  /* ── Flow card grid ── */
  .flow-card-grid {
    display: grid; grid-template-columns: repeat(3, 1fr);
    gap: 10px; padding: 2px 0;
  }
  .flow-card {
    display: flex; flex-direction: column; min-height: 110px;
    padding: 13px 14px; border-radius: 9px;
    border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03);
    transition: background .12s, border-color .12s;
  }
  .flow-card:hover { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.16); }
  .flow-card-header { display: flex; align-items: flex-start; gap: 6px; }
  .flow-card-name { font-size: 13px; font-weight: 700; color: #fff; letter-spacing: -0.01em; line-height: 1.3; flex: 1; word-break: break-word; }
  .flow-card-desc { font-size: 11px; color: rgba(255,255,255,0.50); line-height: 1.5; margin-top: 5px; }
  .flow-card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }
  .flow-card-footer {
    display: flex; align-items: center; justify-content: space-between;
    margin-top: auto; padding-top: 10px;
  }
  .flow-card-meta { font-size: 10px; color: rgba(255,255,255,0.35); }
  .flow-card-actions { display: flex; gap: 2px; flex-shrink: 0; }
  .fc-load-btn {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 7px 14px; border-radius: 7px; font-size: 11px;
    font-family: inherit; font-weight: 600; letter-spacing: 0.04em;
    cursor: pointer; border: 1px solid rgba(255,255,255,0.20);
    background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.75);
    transition: all .14s;
  }
  .fc-load-btn:hover { background: rgba(255,255,255,0.15); border-color: rgba(255,255,255,0.45); color: #fff; }
  .fc-load-btn svg { width: 14px; height: 14px; }
  .fc-icon-btn {
    width: 26px; height: 26px; border-radius: 6px;
    border: 1px solid transparent; background: transparent;
    cursor: pointer; display: flex; align-items: center; justify-content: center;
    transition: all .12s; color: rgba(255,255,255,0.30);
  }
  .fc-icon-btn:hover { background: rgba(255,255,255,0.10); border-color: rgba(255,255,255,0.12); color: rgba(255,255,255,0.85); }
  .fc-icon-btn.danger:hover { background: rgba(239,68,68,0.14); border-color: rgba(239,68,68,0.28); color: #f87171; }
  .fc-icon-btn svg { width: 13px; height: 13px; pointer-events: none; }

  /* ── TARDIS Hierarchy widget ── */
  .th-sync-badge {
    font-size: 10px; color: rgba(99,102,241,0.85);
    background: rgba(99,102,241,0.09); border: 1px solid rgba(99,102,241,0.25);
    border-radius: 3px; padding: 3px 8px; margin-bottom: 4px;
    letter-spacing: 0.03em; line-height: 1.4;
  }
  .hb-sync-status {
    font-size: 10px; padding: 4px 6px; margin-top: 4px; border-radius: 3px;
    min-height: 18px; letter-spacing: 0.02em; line-height: 1.4; word-break: break-all;
  }
  .hb-sync-status.pending { color: rgba(180,140,0,0.85); background: rgba(250,200,0,0.08); }
  .hb-sync-status.ok  { color: rgba(34,197,94,0.90); background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.20); }
  .hb-sync-status.fail { color: rgba(239,68,68,0.85); background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.20); }
  .th-widget { display: flex; flex-direction: column; gap: 6px; }
  .th-row { display: flex; flex-direction: column; gap: 3px; }
  .th-select {
    background: #1e1e1e; border: 1px solid rgba(255,255,255,0.10);
    border-radius: 3px; color: rgba(255,255,255,0.80);
    font-size: 11px; font-family: inherit; padding: 5px 8px;
    outline: none; width: 100%; appearance: none; cursor: pointer;
    transition: border-color .15s;
  }
  .th-select:focus { border-color: rgba(14,165,233,0.55); }
  .th-select:disabled { opacity: .35; cursor: not-allowed; }
  .th-path {
    font-size: 9px; letter-spacing: 0.08em; color: rgba(14,165,233,0.6);
    padding: 4px 2px; word-break: break-all; line-height: 1.5;
  }
  .th-selected-id {
    font-size: 9px; letter-spacing: 0.08em;
    color: rgba(34,197,94,0.8); padding: 3px 6px;
    background: rgba(34,197,94,0.08); border-radius: 3px;
    border: 1px solid rgba(34,197,94,0.2);
  }

  /* ── Run button ── */
  #btn-run {
    margin-left: 0; display: flex; align-items: center; gap: 6px; width: 100%; justify-content: center;
    padding: 5px 14px; border: 1px solid rgba(255,255,255,0.5);
    border-radius: 4px; background: rgba(255,255,255,0.10);
    color: #ffffff; font-size: 11px; font-family: inherit;
    letter-spacing: 0.06em; cursor: pointer; transition: background .15s, opacity .15s;
    white-space: nowrap;
  }
  #btn-run:hover  { background: rgba(255,255,255,0.20); }
  #btn-run:active { background: rgba(255,255,255,0.30); }
  #btn-run:disabled { opacity: .45; cursor: not-allowed; }

  /* ── Node execution overlays ── */
  .node-exec-badge {
    position: absolute; bottom: -1px; left: 50%; transform: translateX(-50%);
    font-size: 8px; letter-spacing: 0.10em; text-transform: uppercase;
    padding: 1px 6px; border-radius: 0 0 4px 4px;
    pointer-events: none;
  }
  .node.exec-running  { border-color: rgba(245,158,11,0.6); animation: pulse-node .8s infinite alternate; }
  .node.exec-success  { border-color: rgba(34,197,94,0.6); }
  .node.exec-error    { border-color: rgba(239,68,68,0.6); }
  .node.exec-skipped  { border-color: rgba(148,163,184,0.4); opacity: 0.55; }
  .node-exec-badge.running { background: rgba(245,158,11,0.15); color: #f59e0b; }
  .node-exec-badge.success { background: rgba(34,197,94,0.12); color: #22c55e; }
  .node-exec-badge.error   { background: rgba(239,68,68,0.12); color: #ef4444; }
  .node-exec-badge.skipped { background: rgba(148,163,184,0.12); color: #94a3b8; }
  @keyframes pulse-node { from { box-shadow: 0 0 0 0 rgba(245,158,11,0.3); }
                          to   { box-shadow: 0 0 0 6px rgba(245,158,11,0); } }
  #cp-output-wrap { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
  #cp-output-label { font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: rgba(255,255,255,0.55); }
  #cp-output-pre, .cp-output-pre {
    background: #1e1e1e; border-radius: 3px; border: 1px solid rgba(255,255,255,0.2);
    color: rgba(255,255,255,0.8); font-size: 9.5px; font-family: monospace;
    padding: 8px; max-height: 160px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
  }
  .cp-output-table-wrap { flex: 1; overflow: auto; margin-top: 0; border-radius: 3px; border: 1px solid rgba(255,255,255,0.2); }
  .cp-output-table { border-collapse: collapse; font-size: 9px; font-family: monospace; color: rgba(255,255,255,0.75); width: 100%; }
  .cp-output-table th { background: rgba(255,255,255,0.15); color: #ffffff; padding: 3px 6px; text-align: left; position: sticky; top: 0; white-space: nowrap; border-bottom: 1px solid rgba(255,255,255,0.2); cursor: pointer; user-select: none; }
  .cp-output-table th:hover { background: rgba(255,255,255,0.22); }
  .cp-output-table th .sort-arrow { margin-left: 4px; opacity: 0.4; font-size: 8px; }
  .cp-output-table th.sort-asc .sort-arrow,
  .cp-output-table th.sort-desc .sort-arrow { opacity: 1; }
  .cp-output-table td { padding: 2px 6px; border-bottom: 1px solid rgba(255,255,255,0.05); white-space: nowrap; }
  .cp-output-table tbody tr:hover td { background: rgba(255,255,255,0.04); }
  .cp-output-info { font-size: 8px; color: rgba(255,255,255,0.3); padding: 3px 6px; text-align: right; }
  .od-search-bar { display: flex; align-items: center; gap: 6px; padding: 5px 8px; background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; }
  .od-search-input { flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 3px; color: #fff; font-size: 10px; padding: 3px 8px; outline: none; font-family: monospace; }
  .od-search-input:focus { border-color: rgba(255,255,255,0.35); background: rgba(255,255,255,0.12); }
  .od-search-input::placeholder { color: rgba(255,255,255,0.25); }
  .od-search-count { font-size: 9px; color: rgba(255,255,255,0.3); white-space: nowrap; }

  /* empty hint */
  #empty-hint {
    position: absolute; inset: 0;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center; gap: 12px;
    pointer-events: none;
  }
  #empty-hint .eh-icon { font-size: 40px; opacity: 0.10; }
  #empty-hint .eh-text {
    font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
    color: rgba(255,255,255,0.16);
  }

  /* ── Settings overlay ── */
  #btn-settings { margin: 2px 0 6px; }
  #btn-settings.active { background: rgba(255,255,255,0.1); color: #fff; }
  #settings-overlay {
    position: absolute; inset: 0; z-index: 20;
    display: flex; background: #1a1a1a;
  }
  #settings-overlay.hidden { display: none; }
  #settings-nav {
    width: 170px; flex-shrink: 0;
    border-right: 1px solid rgba(255,255,255,0.1);
    padding: 16px 8px;
    display: flex; flex-direction: column; gap: 3px;
    background: rgba(255,255,255,0.015);
  }
  .sn-header {
    font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase;
    color: rgba(255,255,255,0.3); padding: 0 10px 10px; margin-bottom: 4px;
    border-bottom: 1px solid rgba(255,255,255,0.07);
  }
  .sn-item {
    padding: 8px 12px; border-radius: 4px; cursor: pointer;
    font-size: 12px; color: rgba(255,255,255,0.55);
    transition: background .12s, color .12s;
  }
  .sn-item:hover  { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.85); }
  .sn-item.active { background: rgba(255,255,255,0.10); color: #fff; }
  #settings-content { flex: 1; overflow-y: auto; padding: 24px 28px; }
  .sset { display: none; }
  .sset.active { display: block; }
  /* Settings form styles */
  .ss-title {
    font-size: 16px; font-weight: 600; color: #fff; margin-bottom: 4px;
  }
  .ss-subtitle {
    font-size: 11px; color: rgba(255,255,255,0.35); margin-bottom: 24px;
  }
  .ss-group {
    background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
    border-radius: 6px; padding: 16px 18px; margin-bottom: 16px;
  }
  .ss-group-title {
    font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
    color: rgba(255,255,255,0.4); margin-bottom: 12px; padding-bottom: 8px;
    border-bottom: 1px solid rgba(255,255,255,0.07);
  }
  .ss-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
  .ss-field:last-child { margin-bottom: 0; }
  .ss-label { font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: rgba(255,255,255,0.4); }
  .ss-input {
    background: #1e1e1e; border: 1px solid rgba(255,255,255,0.14);
    border-radius: 3px; color: rgba(255,255,255,0.85);
    padding: 6px 10px; font-size: 12px; font-family: monospace;
    transition: border-color .15s;
  }
  .ss-input:focus { outline: none; border-color: rgba(255,255,255,0.35); }
  .ss-save-btn {
    margin-top: 8px; padding: 7px 20px; border-radius: 4px; cursor: pointer;
    background: rgba(14,165,233,0.15); border: 1px solid rgba(14,165,233,0.5);
    color: #0ea5e9; font-size: 11px; letter-spacing: 0.08em;
    transition: background .15s;
  }
  .ss-save-btn:hover { background: rgba(14,165,233,0.25); }
  .ss-status { font-size: 10px; color: rgba(255,255,255,0.35); margin-left: 12px; }

  /* ── Settings overlay CSS variables (scoped to overlay) ── */
  #settings-overlay {
    --panel:      rgba(255,255,255,0.04);
    --panel2:     rgba(255,255,255,0.06);
    --border:     rgba(255,255,255,0.12);
    --text-dim:   rgba(255,255,255,0.35);
    --text:       rgba(255,255,255,0.75);
    --text-bright:#ffffff;
    --hover:      rgba(255,255,255,0.05);
    --selected:   rgba(14,165,233,0.08);
    --accent:     #0ea5e9;
    --accent2:    #ef4444;
    --input-bg:   #1e1e1e;
    --status-warn:#f59e0b;
    --status-err: #ef4444;
    --badge-mea-fg:#0ea5e9;
    --badge-mea-bg:rgba(14,165,233,0.1);
  }
  /* ── Recipe editor layout (inside settings) ── */
  #sset-recipes { display: none; }
  #sset-recipes.active { display: flex; flex-direction: row; gap: 0; height: 100%; }
  #settings-content.recipes-active { padding: 0; overflow: hidden; }
  /* ── Engine Mapping editor layout ── */
  #sset-engine-mappings { display: none; }
  #sset-engine-mappings.active { display: flex; flex-direction: row; gap: 0; height: 100%; }
  #settings-content.engine-mappings-active { padding: 0; overflow: hidden; }
  /* ── Step Templates editor layout ── */
  #sset-step-templates { display: none; }
  #sset-step-templates.active { display: flex; flex-direction: row; gap: 0; height: 100%; }
  #settings-content.step-templates-active { padding: 0; overflow: hidden; }
  /* pb-* classes in settings context: wider, more comfortable */
  #sset-step-templates .pb-proc-hdr { padding: 10px 12px; }
  #sset-step-templates .pb-proc-hdr-top { gap: 8px; }
  #sset-step-templates .pb-proc-body { padding: 10px 12px; gap: 8px; }
  #sset-step-templates .pb-input { font-size: 12px; padding: 5px 9px; }
  #sset-step-templates .pb-select { font-size: 11px; padding: 5px 7px; }
  #sset-step-templates .pb-step-row { margin-bottom: 8px; }
  #sset-step-templates .pb-step-bottom { gap: 6px; }
  #sset-step-templates .pb-add-step { font-size: 11px; padding: 5px 10px; }
  #sset-step-templates .pb-add-proc { font-size: 12px; padding: 8px 14px; margin-top: 4px; }
  #sset-step-templates .pb-wrap { gap: 12px; }
  /* ── CIPA Config tab ── */
  #sset-cipa-config { display: none; flex-direction: column; gap: 0; height: 100%; }
  #sset-cipa-config.active { display: flex; }
  #settings-content.cipa-config-active { padding: 0; overflow: hidden; }
  .cipa-section { padding: 16px 20px; overflow-y: auto; }
  .cipa-section-title { font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
    color: rgba(249,115,22,.85); margin-bottom: 12px; }
  .cipa-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 10px; }
  .cipa-table th { background: rgba(249,115,22,.12); color: rgba(249,115,22,.9);
    text-align: left; padding: 5px 8px; font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
  .cipa-table td { padding: 4px 6px; border-bottom: 1px solid rgba(255,255,255,.06); vertical-align: middle; }
  .cipa-table input, .cipa-table select { background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.15);
    color: #fff; border-radius: 3px; padding: 3px 6px; font-size: 12px; width: 100%; }
  .cipa-table select { cursor: pointer; }
  .cipa-role-special td { background: rgba(249,115,22,.05); }
  .cipa-del-btn { background: none; border: none; color: rgba(239,68,68,.7); cursor: pointer; font-size: 14px;
    padding: 2px 4px; }
  .cipa-del-btn:hover { color: #ef4444; }
  .cipa-add-btn { background: rgba(249,115,22,.12); border: 1px solid rgba(249,115,22,.3); color: #f97316;
    border-radius: 4px; padding: 5px 12px; font-size: 12px; cursor: pointer; margin-right: 8px; }
  .cipa-add-btn:hover { background: rgba(249,115,22,.22); }
  .cipa-save-btn { background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.3); color: #22c55e;
    border-radius: 4px; padding: 5px 14px; font-size: 12px; cursor: pointer; }
  .cipa-save-btn:hover { background: rgba(34,197,94,.22); }
  .cipa-pin-grid { display: grid; gap: 2px; font-size: 11px; margin-bottom: 12px; }
  .cipa-esm-ref { display:flex; align-items:flex-start; gap:10px; padding:10px 14px;
    background:rgba(14,165,233,.07); border:1px solid rgba(14,165,233,.2); border-radius:6px;
    margin-bottom:16px; font-size:11px; color:rgba(14,165,233,.9); }
  .cipa-esm-ref .esm-icon { font-size:16px; flex-shrink:0; margin-top:1px; }
  .cipa-esm-ref .esm-text { line-height:1.6; }
  .cipa-esm-ref .esm-text strong { display:block; font-size:10px; letter-spacing:.1em;
    text-transform:uppercase; margin-bottom:2px; color:rgba(14,165,233,.7); }
  .cipa-edit-bar { display:flex; align-items:center; justify-content:space-between;
    margin-bottom:10px; }
  .cipa-edit-btn { display:flex; align-items:center; gap:6px; padding:5px 12px;
    border-radius:4px; font-size:12px; font-weight:600; cursor:pointer; transition:all .15s;
    border:1px solid rgba(249,115,22,.35); color:#f97316; background:rgba(249,115,22,.08); }
  .cipa-edit-btn:hover { background:rgba(249,115,22,.18); }
  .cipa-edit-btn.active { border-color:rgba(239,68,68,.4); color:#ef4444;
    background:rgba(239,68,68,.08); }
  .cipa-edit-btn.active:hover { background:rgba(239,68,68,.18); }
  .cipa-readonly select, .cipa-readonly input[type=text] { pointer-events:none;
    opacity:.55; cursor:default; }
  .cipa-readonly .cipa-del-btn, .cipa-readonly .cipa-add-btn { display:none; }
  .cipa-pin-grid .pg-header { background: rgba(249,115,22,.12); color: rgba(249,115,22,.9);
    padding: 4px 3px; text-align: center; font-size: 10px; font-weight: 700; border-radius: 2px; }
  .cipa-pin-grid .pg-label { padding: 3px 4px; color: rgba(255,255,255,.7); display:flex; align-items:center; }
  .cipa-pin-grid select { background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.12);
    color: #fff; border-radius: 2px; padding: 2px 2px; font-size: 10px; width: 100%; }
  /* ── Mapping table ── */
  .em-table { width:100%; border-collapse:collapse; font-size:12px; margin-bottom:10px; }
  .em-table th {
    font-size:9px; letter-spacing:1.5px; text-transform:uppercase; color:var(--text-dim);
    border-bottom:1px solid var(--border); padding:6px 8px; text-align:left; font-weight:500;
  }
  .em-table td { padding:5px 4px; vertical-align:middle; }
  .em-table tr:hover td { background:var(--hover); }
  .em-td-input {
    width:100%; background:var(--input-bg); border:1px solid transparent; color:var(--text);
    padding:4px 7px; border-radius:3px; font-family:inherit; font-size:12px; outline:none;
  }
  .em-td-input:focus { border-color:var(--accent); }
  .em-del-row { background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:13px; padding:2px 5px; opacity:0.5; }
  .em-del-row:hover { color:var(--accent2); opacity:1; }
  .em-add-row {
    background:none; border:1px dashed var(--border); color:var(--text-dim);
    border-radius:4px; padding:4px 10px; cursor:pointer; font-family:inherit;
    font-size:12px; display:flex; align-items:center; gap:5px; margin-top:4px;
  }
  .em-add-row:hover { border-color:var(--accent); color:var(--accent); }
  .re-sidebar {
    width: 220px; background: var(--panel); border-right: 1px solid var(--border);
    display: flex; flex-direction: column; overflow: hidden; flex-shrink: 0;
  }
  .re-engine-list { flex:1; overflow-y:auto; padding:6px 0; }
  .re-engine-list::-webkit-scrollbar { width:4px; }
  .re-engine-list::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
  .re-engine-item {
    padding: 9px 14px; cursor: pointer; border-left: 2px solid transparent;
    transition: all 0.1s; display:flex; align-items:center; gap:8px;
  }
  .re-engine-item:hover { background: var(--hover); }
  .re-engine-item.selected { background:var(--selected); border-left-color:var(--accent); color:var(--text-bright); }
  .re-engine-item-name { flex:1; font-weight:500; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color: var(--text); }
  .re-del-btn { background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:12px; padding:2px 4px; opacity:0.5; }
  .re-del-btn:hover { color:var(--accent2); opacity:1; }
  .re-footer { padding:12px; border-top:1px solid var(--border); display:flex; align-items:center; gap:8px; }
  .re-save-btn {
    flex:1; padding:8px; background:rgba(14,165,233,0.08); border:1px solid rgba(14,165,233,0.25); border-radius:4px;
    color:rgba(14,165,233,0.85); font-family:inherit; font-size:12px; font-weight:500; cursor:pointer;
  }
  .re-save-btn:hover { background:rgba(14,165,233,0.15); color:rgba(14,165,233,1); border-color:rgba(14,165,233,0.5); }
  .re-editor { flex:1; overflow-y:auto; padding:20px 24px; }
  .re-editor::-webkit-scrollbar { width:4px; }
  .re-editor::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
  .re-engine-header {
    display:flex; align-items:center; gap:12px; margin-bottom:20px;
    padding-bottom:16px; border-bottom:1px solid var(--border);
  }
  .re-field { display:flex; flex-direction:column; gap:4px; }
  .re-label { font-size:10px; color:var(--text-dim); letter-spacing:1.5px; text-transform:uppercase; }
  .re-input {
    background:var(--input-bg); border:1px solid var(--border);
    color:var(--text); padding:6px 10px; border-radius:4px;
    font-family:inherit; font-size:12px; outline:none; transition:border-color 0.15s;
  }
  .re-input:focus { border-color:var(--accent); }
  .re-select {
    background:var(--input-bg); border:1px solid var(--border);
    color:var(--text); padding:6px 10px; border-radius:4px;
    font-family:inherit; font-size:12px; outline:none; cursor:pointer;
  }
  .re-select:focus { border-color:var(--accent); }
  .re-subtabs { display:flex; gap:0; margin-bottom:16px; border-bottom:1px solid var(--border); }
  .re-subtab {
    padding:8px 18px; background:none; border:none; border-bottom:2px solid transparent;
    color:var(--text-dim); cursor:pointer; font-family:inherit; font-size:12px; margin-bottom:-1px;
  }
  .re-subtab:hover { color:var(--text); }
  .re-subtab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
  .re-code-card {
    background:var(--panel); border:1px solid var(--border);
    border-radius:4px; margin-bottom:12px; overflow:hidden;
  }
  .re-code-header {
    display:flex; align-items:center; gap:8px; padding:8px 12px;
    background:var(--panel2); border-bottom:1px solid var(--border);
  }
  .re-code-badge {
    font-size:9px; letter-spacing:1.5px; text-transform:uppercase;
    color:var(--accent); background:rgba(14,165,233,0.1);
    padding:2px 7px; border-radius:4px; flex-shrink:0;
  }
  .re-code-body { padding:12px 14px; }
  .re-param-row { display:grid; gap:8px; align-items:center; margin-bottom:8px; }
  .re-param-header-row { display:grid; gap:8px; margin-bottom:6px; }
  .re-param-header-row span { font-size:9px; color:var(--text-dim); letter-spacing:1px; text-transform:uppercase; }
  .re-drag-handle { cursor:grab; color:var(--text-dim); font-size:16px; padding:0 6px; user-select:none; flex-shrink:0; }
  .re-drag-handle:active { cursor:grabbing; }
  .re-code-card.drag-over, .rating-card.drag-over { border-color:var(--accent); background:var(--selected); }
  .re-code-card.dragging, .rating-card.dragging { opacity:0.4; }
  .re-add-btn {
    background:none; border:1px dashed var(--border); color:var(--text-dim);
    border-radius:4px; padding:5px 12px; cursor:pointer; font-family:inherit;
    font-size:12px; display:flex; align-items:center; gap:6px; margin-top:4px;
  }
  .re-add-btn:hover { border-color:var(--accent); color:var(--accent); }
  /* ── Manual help box ── */
  .manual-help {
    max-width:700px; margin-top:12px;
    border:1px solid var(--border); border-radius:6px;
    overflow:hidden;
  }
  .manual-help-summary {
    list-style:none; padding:9px 14px;
    font-size:10px; font-weight:700; letter-spacing:0.12em; text-transform:uppercase;
    color:var(--text-dim); cursor:pointer; user-select:none;
    display:flex; align-items:center; gap:8px;
    background:var(--panel);
  }
  .manual-help-summary::-webkit-details-marker { display:none; }
  .manual-help-summary::after {
    content:'▸'; margin-left:auto; font-size:9px;
    transition:transform 180ms ease; display:inline-block;
  }
  .manual-help[open] > .manual-help-summary::after { transform:rotate(90deg); }
  .manual-help-summary:hover { color:var(--accent); }
  .manual-help[open] > .manual-help-summary { color:var(--accent); border-bottom:1px solid var(--border); }
  .manual-help-body {
    padding:14px 16px; font-size:12px; line-height:1.7;
    color:var(--text-dim); background:var(--bg);
  }
  .re-icon-btn {
    background:none; border:1px solid var(--border); color:var(--text-dim);
    border-radius:4px; width:26px; height:26px; cursor:pointer; font-size:12px;
    display:flex; align-items:center; justify-content:center; transition:all 0.15s; flex-shrink:0;
  }
  .re-icon-btn:hover { border-color:var(--accent2); color:var(--accent2); }
  .certtype-card {
    background:var(--panel); border:1px solid var(--border);
    border-radius:4px; margin-bottom:10px; display:grid;
    grid-template-columns:1fr 2fr 32px;
    align-items:center; gap:12px; padding:12px 14px;
  }
  .certtype-card:hover { border-color:var(--accent); }
  .rating-card {
    background:var(--panel); border:1px solid var(--border);
    border-radius:4px; margin-bottom:12px; overflow:hidden;
  }
  .rating-card-header { display:flex; align-items:center; gap:10px; padding:10px 14px; background:var(--panel2); }
  .rating-name-input {
    flex:1; background:none; border:none; border-bottom:1px solid transparent;
    color:var(--text); font-family:inherit; font-size:12px; font-weight:500; padding:2px 4px;
  }
  .rating-name-input:focus { outline:none; border-bottom-color:var(--accent); }
  .rating-card-body { padding:14px 16px; }
  .rating-scan-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:8px; }
  .rating-scan-item {
    display:flex; align-items:center; gap:8px; padding:8px 10px;
    border:1px solid var(--border); border-radius:4px; cursor:pointer; transition:all 0.1s;
    font-size:12px; color:var(--text);
  }
  .rating-scan-item:hover { background:var(--hover); }
  .rating-scan-item.checked { background:var(--selected); border-color:var(--accent); }
  .rs-check { width:16px; height:16px; border-radius:3px; border:1px solid var(--border); display:flex; align-items:center; justify-content:center; font-size:10px; color:var(--accent); flex-shrink:0; }
  .rating-scan-item.checked .rs-check { background:var(--accent); border-color:var(--accent); color:#fff; }
  .empty-state { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; gap:12px; color:var(--text-dim); font-size:12px; }
  .empty-icon { font-size:40px; opacity:0.3; }
</style>
</head>
<body>

__SIDEBAR_HTML__

<div id="main">
  <div id="header">
    <div class="header-left">
      <div class="series-label">Flow Architecture Series</div>
      <div class="rfx-num">RFX<span> #12</span></div>
    </div>
    <div class="divider"></div>
    <div class="header-center">
      <div class="header-title">Visual Flow Editor → Node-based<br>API &amp; Data Pipeline Builder</div>
      <div class="header-link">flow-arch.io</div>
    </div>
    <div class="header-right">
      <svg class="icon-circle" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
        <circle cx="14" cy="14" r="12" stroke="#ffffff" stroke-width="1.4"/>
        <circle cx="14" cy="14" r="6.5" stroke="#ffffff" stroke-width="1.0"/>
        <circle cx="14" cy="14" r="2" fill="#ffffff"/>
      </svg>
      <div class="copyright">©2025</div>
      <div id="svc-status">
        <span class="svc-dot" id="svc-tardis" title="TARDIS"></span>
        <span class="svc-label">TARDIS</span>
        <span class="svc-dot" id="svc-hb" title="HYPERBOOST"></span>
        <span class="svc-label">HB</span>
      </div>
    </div>
  </div>

  <!-- Save modal -->
  <div id="modal-save" class="flow-modal hidden">
    <div class="flow-modal-box flow-modal-box-wide" style="overflow-y:visible">
      <div class="flow-modal-title">Flow speichern</div>

      <div style="display:grid;grid-template-columns:300px 1fr;gap:28px;align-items:start">

        <!-- Left: form -->
        <div style="display:flex;flex-direction:column;gap:16px">
          <div style="display:flex;flex-direction:column;gap:10px">
            <input id="save-name-input" class="flow-modal-input" type="text" placeholder="Name" />
            <textarea id="save-desc-input" class="flow-modal-input" rows="3"
              placeholder="Beschreibung (optional)"
              style="resize:none;font-family:inherit;font-size:13px"></textarea>
          </div>
          <div>
            <span class="fm-label">Gruppe</span>
            <div id="save-cat-pills" class="cat-pills"></div>
          </div>
          <div>
            <span class="fm-label">Tags</span>
            <div id="save-tag-chips" class="tag-chip-row"></div>
          </div>
          <div id="save-overwrite-warn" class="save-overwrite-warn hidden">⚠︎ Wird überschrieben</div>
        </div>

        <!-- Right: existing flows -->
        <div style="display:flex;flex-direction:column;gap:8px;min-width:0">
          <span class="fm-label">Vorhandene Flows — klicken zum Überschreiben</span>
          <div id="save-grid-wrap" class="save-grid-wrap"></div>
        </div>

      </div>

      <div class="flow-modal-actions">
        <button class="flow-modal-btn secondary" id="btn-save-cancel">Abbrechen</button>
        <button class="flow-modal-btn primary"   id="btn-save-confirm">Speichern</button>
      </div>
    </div>
  </div>

  <!-- Load modal -->
  <div id="modal-load" class="flow-modal hidden">
    <div class="flow-modal-box flow-modal-box-wide">
      <div style="display:flex;align-items:center;justify-content:space-between">
        <div class="flow-modal-title">Flows</div>
        <button id="btn-cat-mgmt" class="fc-icon-btn" title="Gruppen verwalten" style="width:32px;height:32px;color:rgba(255,255,255,0.45)">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" style="width:15px;height:15px">
            <circle cx="8" cy="8" r="2.2"/>
            <path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.42 1.42M11.53 11.53l1.42 1.42M3.05 12.95l1.42-1.42M11.53 4.47l1.42-1.42"/>
          </svg>
        </button>
      </div>
      <!-- Category management panel (collapsed by default) -->
      <div id="cat-mgmt-panel" class="cat-mgmt-panel hidden">
        <div class="cat-mgmt-list" id="cat-mgmt-list"></div>
        <div class="cat-mgmt-add">
          <input id="cat-mgmt-new-input" class="flow-modal-input" type="text"
            placeholder="Neue Gruppe …" style="flex:1;padding:7px 10px;font-size:12px" />
          <button id="cat-mgmt-add-btn" class="fc-load-btn" style="padding:7px 14px;font-size:11px">Hinzufügen</button>
        </div>
      </div>
      <div class="flow-load-toolbar">
        <input id="load-search" class="flow-modal-input" type="text" placeholder="Suchen …" />
        <div id="load-tag-filter"></div>
      </div>
      <div id="flow-list-wrap"></div>
      <div class="flow-modal-actions">
        <button class="flow-modal-btn secondary" id="btn-load-cancel">Schließen</button>
      </div>
    </div>
  </div>

  <div id="work-area">
    <div id="main-row">

      <div id="palette">
        <div id="palette-nodes">
          <span class="palette-label">Generic</span>
          <div class="palette-item" draggable="true" data-type="api"
               style="border-color:rgba(245,158,11,0.5);color:#f59e0b;background:rgba(245,158,11,0.06)">
            <span>⬡</span><span>API Source</span>
          </div>
          <div class="palette-item" draggable="true" data-type="file_read"
               style="border-color:rgba(52,211,153,0.5);color:#34d399;background:rgba(52,211,153,0.06)">
            <span>◻</span><span>File Read</span>
          </div>
          <div class="palette-item" draggable="true" data-type="file_write"
               style="border-color:rgba(249,115,22,0.5);color:#f97316;background:rgba(249,115,22,0.06)">
            <span>◼</span><span>File Write</span>
          </div>
          <div class="palette-item" draggable="true" data-type="merge"
               style="border-color:rgba(236,72,153,0.5);color:#ec4899;background:rgba(236,72,153,0.06)">
            <span>⊞</span><span>Merge</span>
          </div>
          <div class="palette-item" draggable="true" data-type="display"
               style="border-color:rgba(100,116,139,0.5);color:#64748b;background:rgba(100,116,139,0.06)">
            <span>▤</span><span>Display</span>
          </div>
          <div class="palette-item" draggable="true" data-type="group"
               style="border-color:rgba(99,102,241,0.5);color:#6366f1;background:rgba(99,102,241,0.06)">
            <span>▭</span><span>Group</span>
          </div>
          <div class="palette-item" draggable="true" data-type="note"
               style="border-color:rgba(250,204,21,0.5);color:#facc15;background:rgba(250,204,21,0.06)">
            <span>✎</span><span>Note</span>
          </div>
          <div class="palette-item" draggable="true" data-type="code"
               style="border-color:rgba(167,139,250,0.5);color:#a78bfa;background:rgba(167,139,250,0.06)">
            <span>⌥</span><span>Code</span>
          </div>
          <div class="palette-item" draggable="true" data-type="plot"
               style="border-color:rgba(14,165,233,0.5);color:#0ea5e9;background:rgba(14,165,233,0.06)">
            <span>◬</span><span>Plot</span>
          </div>
          <div class="palette-item" draggable="true" data-type="pdf_compose"
               style="border-color:rgba(226,168,41,0.5);color:#e2a829;background:rgba(226,168,41,0.06)">
            <span>⧉</span><span>PDF Compose</span>
          </div>
          <div class="palette-item" draggable="true" data-type="teams_notify"
               style="border-color:rgba(98,100,167,0.5);color:#6264a7;background:rgba(98,100,167,0.06)">
            <span>✉</span><span>Teams Notify</span>
          </div>
          <div class="palette-sep"></div>
          <span class="palette-label">TARDIS</span>
          <div class="palette-item" draggable="true" data-type="tardis_query"
               style="border-color:rgba(14,165,233,0.5);color:#0ea5e9;background:rgba(14,165,233,0.06)">
            <span>⬡</span><span>TARDIS Query</span>
          </div>
          <div class="palette-item" draggable="true" data-type="scan_query"
               style="border-color:rgba(14,165,233,0.5);color:#0ea5e9;background:rgba(14,165,233,0.06)">
            <span>⬡</span><span>Scan Query</span>
          </div>
          <div class="palette-item" draggable="true" data-type="select"
               style="border-color:rgba(255,255,255,0.5);color:#ffffff;background:rgba(255,255,255,0.06)">
            <span>⊏</span><span>Select</span>
          </div>
          <div class="palette-item" draggable="true" data-type="filter"
               style="border-color:rgba(56,189,248,0.5);color:#38bdf8;background:rgba(56,189,248,0.06)">
            <span>⊐</span><span>Filter</span>
          </div>
          <div class="palette-item" draggable="true" data-type="transpose"
               style="border-color:rgba(167,139,250,0.5);color:#a78bfa;background:rgba(167,139,250,0.06)">
            <span>⊟</span><span>Transpose</span>
          </div>
          <div class="palette-item" draggable="true" data-type="branch"
               style="border-color:rgba(167,139,250,0.5);color:#a78bfa;background:rgba(167,139,250,0.06)">
            <span>⑂</span><span>Branch</span>
          </div>
          <div class="palette-sep"></div>
          <span class="palette-label">Engine Specific</span>
          <div class="palette-item" draggable="true" data-type="recipe"
               style="border-color:rgba(245,158,11,0.5);color:#f59e0b;background:rgba(245,158,11,0.06)">
            <span>◈</span><span>Recipe</span>
          </div>
          <div class="palette-item" draggable="true" data-type="cert_build"
               style="border-color:rgba(34,197,94,0.5);color:#22c55e;background:rgba(34,197,94,0.06)">
            <span>◈</span><span>Cert Build</span>
          </div>
          <div class="palette-item" draggable="true" data-type="check"
               style="border-color:rgba(34,197,94,0.5);color:#22c55e;background:rgba(34,197,94,0.06)">
            <span>✓</span><span>Check</span>
          </div>
          <div class="palette-item" draggable="true" data-type="cipa_cert"
               style="border-color:rgba(249,115,22,0.5);color:#f97316;background:rgba(249,115,22,0.06)">
            <span>◉</span><span>CIPA Cert</span>
          </div>
          <div class="palette-sep"></div>
          <span class="palette-label">Hyperboost</span>
          <div class="palette-item" draggable="true" data-type="hb_read"
               style="border-color:rgba(167,139,250,0.5);color:#a78bfa;background:rgba(167,139,250,0.06)">
            <span>⇡</span><span>HB Read</span>
          </div>
          <div class="palette-item" draggable="true" data-type="hb_write"
               style="border-color:rgba(244,63,94,0.5);color:#f43f5e;background:rgba(244,63,94,0.06)">
            <span>⇣</span><span>HB Write</span>
          </div>
          <div class="palette-item" draggable="true" data-type="hb_patch"
               style="border-color:rgba(251,146,60,0.5);color:#fb923c;background:rgba(251,146,60,0.06)">
            <span>✎</span><span>HB Patch</span>
          </div>
          <div class="palette-item" draggable="true" data-type="pdf_source"
               style="border-color:rgba(249,115,22,0.5);color:#f97316;background:rgba(249,115,22,0.06)">
            <span>◻</span><span>WRB PDF</span>
          </div>
          <div class="palette-item" draggable="true" data-type="template_steps"
               style="border-color:rgba(139,92,246,0.5);color:#8b5cf6;background:rgba(139,92,246,0.06)">
            <span>⬡</span><span>Template Steps</span>
          </div>
          <div class="palette-item" draggable="true" data-type="custom_steps"
               style="border-color:rgba(100,116,139,0.5);color:#64748b;background:rgba(100,116,139,0.06)">
            <span>⊟</span><span>Custom Steps</span>
          </div>
          <div class="palette-item" draggable="true" data-type="cluster_check"
               style="border-color:rgba(16,185,129,0.5);color:#10b981;background:rgba(16,185,129,0.06)">
            <span>✔</span><span>Cluster Check</span>
          </div>
          <div class="palette-sep"></div>
          <button id="btn-settings" class="btn-flow" onclick="toggleSettings()">◈ Settings</button>
        </div>
        <div id="palette-actions">
          <button id="btn-run">▶ Run</button>
          <button id="btn-save" class="btn-flow">💾 Save</button>
          <button id="btn-load" class="btn-flow">📂 Load</button>
          <button id="btn-clear" class="btn-flow btn-danger" title="Canvas leeren">⊘ Clear</button>
            <span id="flow-badge">Neuer Flow</span>
        </div>
      </div>

      <div id="canvas-area">
        <div id="settings-overlay" class="hidden">
          <div id="settings-nav">
            <div class="sn-header">EINSTELLUNGEN</div>
            <div class="sn-item active" data-section="connections" onclick="switchSettingsSection('connections')">🔌 Verbindungen</div>
            <div class="sn-item" data-section="recipes" onclick="switchSettingsSection('recipes')">◈ Recipes</div>
            <div class="sn-item" data-section="engine-mappings" onclick="switchSettingsSection('engine-mappings')">◈ WRB PDF</div>
            <div class="sn-item" data-section="step-templates" onclick="switchSettingsSection('step-templates')">⊟ Custom Steps</div>
            <div class="sn-item" data-section="node-defaults" onclick="switchSettingsSection('node-defaults')">⬡ Node Defaults</div>
            <div class="sn-item" data-section="cipa-config" onclick="switchSettingsSection('cipa-config')">◉ CIPA Config</div>
          </div>
          <div id="settings-content">
            <div id="sset-connections"     class="sset active"></div>
            <div id="sset-recipes"         class="sset"></div>
            <div id="sset-engine-mappings" class="sset"></div>
            <div id="sset-step-templates"  class="sset"></div>
            <div id="sset-node-defaults"   class="sset"></div>
            <div id="sset-cipa-config"     class="sset"></div>
          </div>
        </div>
        <svg id="svg-layer"><path id="temp-path" d=""/></svg>
        <div id="empty-hint">
          <div class="eh-icon">⬢</div>
          <div class="eh-text">Nodes aus der Leiste auf die Fläche ziehen</div>
        </div>
      </div>
      <div id="config-panel">
        <div id="cp-header">
          <span id="cp-icon"></span>
          <span id="cp-title">Konfiguration</span>
          <span id="cp-close">×</span>
        </div>
        <div id="cp-body"></div>
      </div>
    </div>
    <div id="output-drawer">
      <div id="od-resize-handle"></div>
      <div id="od-header">
        <span id="od-title">OUTPUT</span>
        <div id="od-tabs"></div>
        <button id="od-close">×</button>
      </div>
      <div id="od-body"></div>
    </div>
  </div>
</div>

<script>
const TIP_DEFAULTS = [
  'BELL_MOD','BELL_SN','CSN','CUSTOMER_ENG_MOD','CUSTOMER_NAME','CUSTOMER_TCNAME',
  'EEC_PN','EEC_SV','EEC_SWPN','ENGINE_RATING_B20','ENGINE_RATING_B22','ENGINE_RATING_B22B1',
  'ENGINE_RATING_B24','ENGINE_RATING_B24B1','ENGINE_RATING_B26','ENGINE_RATING_B26B2',
  'ENGINE_RATING_B27','ESM','EXH_SN','EXH_TYP','IDPLUG','NOZZLETYPE','OIL_TYPE',
  'SBSTAND','TD_PN','TD_SN','TestParam','TSN','WO_NUMBER','WORKSCOPE','YTESTID',
];
const METADATA_FIELDS = [
  'esn','build_number','engine_type','test_type','customer','wo_number',
  'serial_number','workscope','csn','tsn','operator','engineer',
];
__SIDEBAR_JS__
(function () {

const NODE_TYPES = {
  // ── Generic ──────────────────────────────────────────────────────
  api: {
    label: 'API Source', icon: '⬡', color: '#f59e0b', inputs: [], outputs: ['data'],
    fields: [
      { key: 'url',          label: 'Endpoint URL',   type: 'text',     placeholder: 'https://api.example.com/data', required: true },
      { key: 'method',       label: 'HTTP Method',    type: 'select',   options: ['GET','POST','PUT','DELETE'] },
      { key: 'apiKey',       label: 'API Key / Token',type: 'text',     placeholder: 'Bearer eyJ...' },
      { key: 'queryParams',  label: 'Query Params',   type: 'text',     placeholder: 'key=val&foo=bar' },
      { key: 'body',         label: 'Body (JSON)',     type: 'textarea', placeholder: '{"key": "value"}' },
      { key: 'responsePath', label: 'Response Path',  type: 'text',     placeholder: 'data.items' },
    ]
  },
  scan_query: {
    label: 'Scan Query', icon: '⬡', color: '#0ea5e9', inputs: ['tardis'], outputs: ['measurements'],
    fields: [
      { key: 'testId',   label: 'Test-ID (optional, wenn TARDIS Query verbunden)', type: 'text', placeholder: 'z.B. 123456' },
      { key: 'scanCode', label: 'Scan-Code', type: 'text',   placeholder: 'z.B. DryMotor', required: true },
      { key: 'scanNr',   label: 'Scan-Nr (leer = alle)', type: 'text', placeholder: 'z.B. 1' },
    ]
  },
  cert_build: {
    label: 'Cert Build', icon: '◈', color: '#22c55e',
    inputs: ['recipe', 'tardis', 'metadata'],
    outputs: ['file'],
    fields: [
      { key: 'esn', label: 'ESN',         type: 'text', placeholder: 'Engine Serial Nr.' },
      { key: 'wbs', label: 'WBS / Order', type: 'text', placeholder: 'Auftragsnummer' },
    ]
  },
  cipa_cert: {
    label: 'CIPA Cert', icon: '◉', color: '#f97316',
    inputs: ['hb_read', 'tardis'],
    outputs: ['file'],
    fields: [
      { key: 'templatePath', label: 'Template (.docx)', type: 'text',
        placeholder: 'L:/...Vorlagen/CIPA_CFM56-7B.docx', required: true },
    ]
  },
  recipe: {
    label: 'Recipe', icon: '◈', color: '#f59e0b', inputs: [], outputs: ['recipe'],
    fields: [
      { key: 'engineType', label: 'Engine Type', type: 'recipe-select', required: true },
    ]
  },
  check: {
    label: 'Check', icon: '✓', color: '#22c55e', inputs: ['data', 'recipe'], outputs: ['checked'],
    fields: [
      { key: 'title', label: 'Output-Titel', type: 'text', placeholder: 'Check-Ergebnis' },
      { key: 'rules', label: 'Grenzwert-Regeln (eine pro Zeile)', type: 'textarea',
        placeholder: 'N1 > 5000\nEGT < 800\n5000 < N1 < 8000\n# Kommentare mit #' },
    ]
  },
  select: {
    label: 'Select Columns', icon: '⊏', color: '#ffffff', inputs: ['in'], outputs: ['out'],
    fields: [
      { key: 'columns', label: 'Spalten (komma- oder zeilengetrennt)', type: 'textarea',
        placeholder: 'Time\nN1\nEGT\nP3', required: true },
    ]
  },
  filter: {
    label: 'Filter Rows', icon: '⊐', color: '#38bdf8', inputs: ['in'], outputs: ['out'],
    fields: [
      { key: 'rules', label: 'Filter-Regeln (eine pro Zeile)', type: 'textarea',
        placeholder: 'N1 > 5000\nEGT < 850\n5000 < N1 < 8000\n# Kommentare mit #' },
    ]
  },
  transpose: {
    label: 'Transpose', icon: '⊟', color: '#a78bfa', inputs: ['in'], outputs: ['out'],
    fields: [
      { key: 'labelCol', label: 'Bezeichnungs-Spalte', type: 'text', placeholder: 'Kanal' },
    ]
  },
  file_read: {
    label: 'File Read', icon: '◻', color: '#34d399', inputs: [], outputs: ['data'],
    fields: [
      { key: 'filePath',  label: 'Dateipfad',  type: 'text',   placeholder: 'C:/daten/messung.csv', required: true },
      { key: 'fileType',  label: 'Dateityp',   type: 'select', options: ['csv','json','xlsx','xls'] },
      { key: 'delimiter', label: 'Trennzeichen (CSV)', type: 'text', placeholder: ',' },
      { key: 'sheet',     label: 'Blattname (XLSX)',   type: 'text', placeholder: 'Sheet1' },
    ]
  },
  file_write: {
    label: 'File Write', icon: '◼', color: '#f97316', inputs: ['data'], outputs: [],
    fields: [
      { key: 'filePath',  label: 'Dateipfad',  type: 'text',   placeholder: 'C:/output/ergebnis.csv', required: true },
      { key: 'fileType',  label: 'Dateityp',   type: 'select', options: ['csv','json','xlsx','xls'] },
      { key: 'delimiter', label: 'Trennzeichen (CSV)', type: 'text', placeholder: ',' },
      { key: 'sheet',     label: 'Blattname (XLSX)',   type: 'text', placeholder: 'Sheet1' },
    ]
  },
  merge: {
    label: 'Merge', icon: '⊞', color: '#ec4899', inputs: ['a','b'], outputs: ['out'],
    fields: [
      { key: 'strategy', label: 'Strategie', type: 'select', options: ['concat','zip','join-key'] },
      { key: 'joinKey',  label: 'Join Key',  type: 'text',   placeholder: 'z.B. esn' },
    ]
  },
  branch: {
    label: 'Branch', icon: '⑂', color: '#a78bfa',
    inputs: ['A', 'B'],
    outputs: ['true', 'false'],
    fields: [
      { key: 'condition', label: 'Bedingung', type: 'textarea',
        placeholder: 'Ein Input:  trim_after != \'\'\nZwei Inputs: a[\'trim_level_before\'] != b[\'trim_after\']\nTabelle:     any: N1 > 5000\n             all: EGT < 800\n             none: N1 < 0' },
      { key: 'tableMode', label: 'Tabellen-Modus (any / all / none)', type: 'select',
        options: ['any', 'all', 'none'] },
    ]
  },
  // ── TARDIS domain ─────────────────────────────────────────────────
  pdf_source: {
    label: 'WRB PDF', icon: '◻', color: '#f97316', inputs: [], outputs: ['metadata'],
    fields: [
      { key: 'filePath',        label: 'PDF Dateipfad',                    type: 'text', placeholder: '/pfad/zur/datei.pdf', required: true },
      { key: 'engineMappingId', label: 'Engine Mapping (leer = auto)',      type: 'engine-mapping-select' },
    ]
  },
  tardis_query: {
    label: 'TARDIS Query', icon: '⬡', color: '#0ea5e9', inputs: ['hb_sync'], outputs: ['measurements'],
    fields: [
      { key: 'channelgroupId', label: 'TARDIS Hierarchy', type: 'tardis-hierarchy', required: true },
      { key: 'limit',          label: 'Row Limit',        type: 'number', placeholder: '500' },
    ]
  },
  hb_read: {
    label: 'HB Read', icon: '⇡', color: '#a78bfa', inputs: [], outputs: ['order_data'],
    fields: [
      { key: 'orderId',    label: 'Test Order',                  type: 'hb-order-select', required: true },
      { key: 'attributes', label: 'Statische Werte (key=value)', type: 'textarea',
        placeholder: 'tester_name=Max Mustermann\nstation=MTU-L-4' },
    ]
  },
  hb_patch: {
    label: 'HB Patch', icon: '✎', color: '#fb923c',
    inputs: ['order_id'], outputs: ['result'],
    fields: [
      { key: 'orderId',    label: 'Test Order (wenn kein Input)', type: 'hb-order-select' },
      { key: 'attributes', label: 'Attribute (KEY=VALUE, eine pro Zeile)', type: 'textarea',
        placeholder: 'engineer-name=Hans Müller\nWO_NUMBER=WO-2026-042' },
    ]
  },
  hb_write: {
    label: 'HB Write', icon: '⇣', color: '#f43f5e', inputs: ['metadata', 'steps'], outputs: ['order_id'],
    fields: [
      { key: 'orderName',       label: 'Order Name Format (leer = auto)',   type: 'text',     placeholder: '{engine_type}_{esn}_{build}' },
      { key: 'engineerName',    label: 'Engineer Name',                     type: 'text',     placeholder: 'z.B. Max Mustermann' },
      { key: 'operatorName',    label: 'Operator Name 1',                   type: 'text',     placeholder: 'z.B. M. Mustermann' },
      { key: 'operatorName2',   label: 'Operator Name 2',                   type: 'text',     placeholder: 'optional' },
      { key: 'spteName',        label: 'SPTE Name',                         type: 'text',     placeholder: 'z.B. CFM56-7B-SPTE-1' },
      { key: 'uutName',         label: 'UUT Name',                          type: 'text',     placeholder: 'z.B. CFM56-7B' },
      { key: 'standardName',    label: 'Standard Name',                     type: 'text',     placeholder: 'z.B. Multi-Rating-SAC' },
      { key: 'configName',      label: 'Config Name',                       type: 'text',     placeholder: 'z.B. Test-Config' },
      { key: 'testDescription', label: 'Test Description',                  type: 'text',     placeholder: 'optional' },
      { key: 'tipOverrides',    label: 'TIP Overrides (KEY=VALUE, eine pro Zeile)', type: 'textarea',
        placeholder: 'WO_NUMBER=WO-2026-001\nNOZZLETYPE=SAC' },
      { key: 'onDuplicate',    label: 'Bei Duplikat (ESN+WO)',                    type: 'select',
        options: ['error','skip','force'] },
    ]
  },
  template_steps: {
    label: 'Template Steps', icon: '⬡', color: '#8b5cf6', inputs: [], outputs: ['steps'],
    fields: [
      { key: 'mode',       label: 'Modus',    type: 'select', options: ['boost','dashboard'], required: true },
      { key: 'templateId', label: 'Template', type: 'template-steps-select' },
    ]
  },
  custom_steps: {
    label: 'Custom Steps', icon: '⊟', color: '#64748b', inputs: [], outputs: ['steps'],
    fields: [
      { key: 'templateId', label: 'Step Template', type: 'step-template-select' },
    ]
  },
  cluster_check: {
    label: 'Cluster Check', icon: '✔', color: '#10b981', inputs: ['metadata'], outputs: [],
    fields: [
      { key: 'rules', label: 'Regeln (eine pro Zeile: "Cluster Key == Wert" oder "Key not_empty")',
        type: 'textarea', placeholder:
          'General Information | Combustor Version == SAC\nTestrun Requirement | Break-In Run Required == Yes\nGeneral Information | Thrust Rating not_empty' },
    ]
  },
  display: {
    label: 'Display', icon: '▤', color: '#64748b', inputs: ['in'], outputs: ['table'],
    fields: [
      { key: 'displayType', label: 'Anzeigetyp', type: 'select', options: ['table','json','chart'] },
      { key: 'title',       label: 'Titel',      type: 'text',   placeholder: 'Daten-Preview' },
    ]
  },
  group: {
    label: 'Group', icon: '▭', color: '#6366f1', inputs: [], outputs: [],
    fields: [
      { key: 'label', label: 'Bezeichnung', type: 'text',   placeholder: 'Gruppe …' },
      { key: 'color', label: 'Farbe',       type: 'select', options: ['indigo','teal','amber','rose','slate'] },
    ]
  },
  note: {
    label: 'Note', icon: '✎', color: '#facc15', inputs: [], outputs: [],
    fields: [
      { key: 'text', label: 'Text', type: 'textarea', placeholder: 'Notiz …' },
    ]
  },
  teams_notify: {
    label: 'Teams Notify', icon: '✉', color: '#6264a7', inputs: ['data'], outputs: [],
    fields: [
      { key: 'webhookUrl',  label: 'Webhook URL',                    type: 'text',
        placeholder: 'https://...powerautomate.com/...', required: true },
      { key: 'proxyUrl',    label: 'Proxy URL (leer = kein Proxy)',  type: 'text',
        placeholder: 'http://cpwebproxy.muc.mtu.dasa.de:8888' },
      { key: 'title',       label: 'Titel  (z.B. {engine_type})',    type: 'text',
        placeholder: 'Prüfstand {engine_type} – {test_id}' },
      { key: 'infoText',    label: 'Info Text  (Template-Vars: {key})', type: 'textarea',
        placeholder: 'Engine: {engine_type}\nStatus: {overall_ok}' },
      { key: 'color',       label: 'Card Farbe',                     type: 'select',
        options: ['blau', 'grün', 'rot', 'gelb'] },
      { key: 'includeData', label: 'Violation-Details anhängen',     type: 'checkbox' },
    ]
  },
  code: {
    label: 'Code', icon: '⌥', color: '#a78bfa', inputs: ['in', 'in2'], outputs: ['out'],
    fields: [
      { key: 'code', label: 'Python Code', type: 'code',
        placeholder: '# data = Input von Port 0\n# pd (pandas) und np (numpy) verfügbar\nresult = data' },
    ]
  },
  plot: {
    label: 'Plot', icon: '◬', color: '#0ea5e9', inputs: ['data'], outputs: ['figure'],
    fields: [
      { key: 'title',     label: 'Titel',     type: 'text',   placeholder: 'Chart Titel' },
      { key: 'chartType', label: 'Chart Typ', type: 'select', options: ['line','scatter'] },
      { key: 'xAxis',     label: 'X-Achse',   type: 'text',   placeholder: 'Spaltenname oder leer = Index' },
      { key: 'yAxes',     label: 'Y-Achsen',  type: 'text',   placeholder: 'N1_corr_M2,EGT (kommagetrennt)' },
      { key: 'limitMin',  label: 'Limit Min', type: 'number', placeholder: '' },
      { key: 'limitMax',  label: 'Limit Max', type: 'number', placeholder: '' },
    ]
  },
  pdf_compose: {
    label: 'PDF Compose', icon: '⧉', color: '#e2a829',
    inputs: ['meta', 'Sek.1', 'Sek.2', 'Sek.3', 'Sek.4', 'Sek.5', 'Sek.6', 'Sek.7', 'Sek.8'],
    outputs: ['file'],
    fields: [
      { key: 'title',          label: 'Titel',            type: 'text',   placeholder: 'Report Titel' },
      { key: 'subtitle',       label: 'Untertitel',       type: 'text',   placeholder: '' },
      { key: 'meta_esn',       label: 'ESN (manuell)',    type: 'text',   placeholder: 'auto aus Meta-Port' },
      { key: 'meta_wo',        label: 'WO (manuell)',     type: 'text',   placeholder: 'auto aus Meta-Port' },
      { key: 'meta_author',    label: 'Erstellt von',     type: 'text',   placeholder: '' },
      { key: 'plots_per_page', label: 'Plots pro Seite',  type: 'select', options: ['2','3'] },
    ]
  },
};

const canvasArea  = document.getElementById('canvas-area');
const svgLayer    = document.getElementById('svg-layer');
const tempPath    = document.getElementById('temp-path');
const emptyHint   = document.getElementById('empty-hint');
const configPanel = document.getElementById('config-panel');
const cpIcon      = document.getElementById('cp-icon');
const cpTitle     = document.getElementById('cp-title');
const cpBody      = document.getElementById('cp-body');
const cpClose     = document.getElementById('cp-close');

let nodes        = [];
let connections  = [];
let nodeCount    = 0;
let connCount    = 0;
let draggingNode   = null;   // { node:{id,el}, ox, oy }
let connectState   = null;   // { fromNodeId, fromPortIdx, x1, y1 }
let resizingGroup  = null;   // { el, startX, startY, startW, startH }
let dragType     = null;
let selectedNode = null;   // id of currently selected node
let nodeDefaults = {};     // loaded from settings.json on startup

// ── load node defaults at startup ────────────────────────────────
(async () => {
  try {
    const r = await fetch('/api/settings');
    const d = await r.json();
    nodeDefaults = d.node_defaults || {};
  } catch(_) {}
})();

// ── helpers ───────────────────────────────────────────────────────

function canvasRect() { return canvasArea.getBoundingClientRect(); }

function getPortCenter(nodeId, portIdx, isOutput) {
  const nd = nodes.find(n => n.id === nodeId);
  if (!nd) return { x: 0, y: 0 };
  const ports = nd.el.querySelectorAll(isOutput ? '.port-out' : '.port-in');
  const port  = ports[portIdx];
  if (!port) return { x: 0, y: 0 };
  const pr = port.getBoundingClientRect();
  const cr = canvasRect();
  return { x: pr.left + pr.width / 2 - cr.left, y: pr.top + pr.height / 2 - cr.top };
}

function bezier(x1, y1, x2, y2) {
  const dx = Math.max(Math.abs(x2 - x1) * 0.55, 60);
  return `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`;
}

function updateConnection(conn) {
  if (conn.groupProxy) {
    const gnd = nodes.find(n => n.id === conn.groupProxy.groupId);
    if (gnd) {
      // use style.left/top + stored dimensions — reliable even before browser layout
      const gx = parseFloat(gnd.el.style.left);
      const gy = parseFloat(gnd.el.style.top) + 14;  // collapsed height 28px / 2
      const gw = gnd.el._groupW || 200;
      if (conn.groupProxy.side === 'in') {
        const p1 = getPortCenter(conn.fromNodeId, conn.fromPortIdx, true);
        conn.el.setAttribute('d', bezier(p1.x, p1.y, gx, gy));
      } else {
        const p2 = getPortCenter(conn.toNodeId, conn.toPortIdx, false);
        conn.el.setAttribute('d', bezier(gx + gw, gy, p2.x, p2.y));
      }
      return;
    }
  }
  const p1 = getPortCenter(conn.fromNodeId, conn.fromPortIdx, true);
  const p2 = getPortCenter(conn.toNodeId,   conn.toPortIdx,   false);
  conn.el.setAttribute('d', bezier(p1.x, p1.y, p2.x, p2.y));
}

function updateAllConnections() { connections.forEach(updateConnection); }

function getGroupColor(groupId) {
  const gnd = nodes.find(n => n.id === groupId && n.typeId === 'group');
  if (!gnd) return null;
  return (gnd.el._groupColors || {})[gnd.el._groupColor || 'indigo'] || null;
}

function updateNodeGroupBadge(nd) {
  let badge = nd.el.querySelector('.group-badge');
  if (nd.config.groupId != null) {
    const col = getGroupColor(nd.config.groupId);
    if (!badge) {
      badge = document.createElement('span');
      badge.className = 'group-badge';
      const hdr = nd.el.querySelector('.node-header');
      if (hdr) hdr.appendChild(badge);
    }
    if (badge) badge.style.background = col ? col.border : 'rgba(255,255,255,0.3)';
  } else {
    if (badge) badge.remove();
  }
}

function toggleGroup(groupId) {
  const gnd = nodes.find(n => n.id === groupId);
  if (!gnd) return;
  const el = gnd.el;
  el._collapsed = !el._collapsed;
  const contained = nodes.filter(n => n.config.groupId === groupId);
  if (el._collapsed) {
    el._preCollapseH = el._groupH;
    contained.forEach(nd => {
      nd.el.style.display = 'none';
      connections.filter(c => c.fromNodeId === nd.id || c.toNodeId === nd.id).forEach(c => {
        const fromIn = contained.some(n => n.id === c.fromNodeId);
        const toIn   = contained.some(n => n.id === c.toNodeId);
        if (fromIn && toIn) {
          c.el.style.display = 'none';   // internal: hide
        } else {
          c.groupProxy = { groupId, side: fromIn ? 'out' : 'in' };  // external: redirect
          c.el.style.display = '';
        }
      });
    });
    el._groupH = 28;
    el.style.minHeight = '28px';
    el.applyGroupStyle();
    if (el._collapseBtn) el._collapseBtn.textContent = '▸';
    updateAllConnections();
  } else {
    el._groupH = el._preCollapseH || 200;
    el.style.minHeight = '';
    connections.forEach(c => { if (c.groupProxy?.groupId === groupId) delete c.groupProxy; });
    contained.forEach(nd => {
      nd.el.style.display = '';
      connections.filter(c => c.fromNodeId === nd.id || c.toNodeId === nd.id)
                 .forEach(c => c.el.style.display = '');
    });
    el.applyGroupStyle();
    if (el._collapseBtn) el._collapseBtn.textContent = '▾';
    updateAllConnections();
  }
  gnd.config.collapsed    = el._collapsed;
  gnd.config.preCollapseH = el._preCollapseH;
}

function updateEmptyHint() { emptyHint.style.display = nodes.length === 0 ? 'flex' : 'none'; }

function hexAlpha(hex, a) {
  const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
  return `rgba(${r},${g},${b},${a})`;
}

// ── config panel ──────────────────────────────────────────────────

function updateStatusDot(nodeId) {
  const nd   = nodes.find(n => n.id === nodeId);
  if (!nd) return;
  const type = NODE_TYPES[nd.typeId];
  const dot  = nd.el.querySelector('.node-status');
  if (!dot || !type.fields || type.fields.length === 0) return;
  const reqFields = type.fields.filter(f => f.required);
  const allFilled = reqFields.every(f => nd.config[f.key] && String(nd.config[f.key]).trim() !== '');
  const anyFilled = type.fields.some(f => nd.config[f.key] && String(nd.config[f.key]).trim() !== '');
  dot.classList.toggle('ok',      reqFields.length > 0 && allFilled);
  dot.classList.toggle('partial', !allFilled && anyFilled);
}

// ── TARDIS hierarchy cascade widget ──────────────────────────────

const TH_LEVELS = [
  { label: 'Projekt',      fetchUrl: (p) => '/api/tardis/projects',                    paramKey: null },
  { label: 'Pool',         fetchUrl: (p) => `/api/tardis/pools?project=${encodeURIComponent(p.project)}`, paramKey: 'project' },
  { label: 'Test',         fetchUrl: (p) => `/api/tardis/tests?project=${encodeURIComponent(p.project)}&pool=${encodeURIComponent(p.pool)}`, paramKey: 'pool' },
  { label: 'Test Step',    fetchUrl: (p) => `/api/tardis/teststeps?test_id=${encodeURIComponent(p.test_id)}`, paramKey: 'test_id' },
  { label: 'Measurement',  fetchUrl: (p) => `/api/tardis/measurements?teststep_id=${encodeURIComponent(p.teststep_id)}`, paramKey: 'teststep_id' },
  { label: 'Channel Group',fetchUrl: (p) => `/api/tardis/channelgroups?measurement_id=${encodeURIComponent(p.measurement_id)}`, paramKey: 'measurement_id' },
];

// Maps level index → sel key (used for restore)
const TH_SEL_KEYS = ['project', 'pool', 'test_id', 'teststep_id', 'measurement_id', 'channelgroup_id'];
// Maps level index → nd.config key where intermediate selection is stored
const TH_CFG_KEYS = ['th_project', 'th_pool', 'th_test_id', 'th_teststep_id', 'th_measurement_id'];

function renderTardisHierarchy(field, nd, container) {
  if (nd.config._synced_from_hb) {
    const badge = document.createElement('div');
    badge.className = 'th-sync-badge';
    badge.textContent = `🔗 Sync: ${nd.config._synced_from_hb}`;
    container.appendChild(badge);
  }
  const widget  = document.createElement('div');
  widget.className = 'th-widget';

  // State: restore all levels from nd.config (persisted on every selection)
  const sel = {
    project:        nd.config.th_project        || '',
    pool:           nd.config.th_pool           || '',
    test_id:        nd.config.th_test_id        || '',
    teststep_id:    nd.config.th_teststep_id    || '',
    measurement_id: nd.config.th_measurement_id || '',
    channelgroup_id: nd.config[field.key]       || ''
  };

  const selects = [];  // one <select> per level
  const rows    = [];

  // Selected-ID display
  const idBadge = document.createElement('div');
  idBadge.className = 'th-selected-id';
  idBadge.style.display = nd.config[field.key] ? 'block' : 'none';
  idBadge.textContent   = nd.config[field.key] ? `CG-ID: ${nd.config[field.key]}` : '';

  async function loadLevel(lvlIdx, params) {
    // Hide all levels below
    for (let i = lvlIdx; i < TH_LEVELS.length; i++) {
      if (rows[i]) rows[i].style.display = 'none';
      if (selects[i]) { selects[i].innerHTML = ''; selects[i].disabled = true; }
    }

    const lvl = TH_LEVELS[lvlIdx];
    if (!rows[lvlIdx]) return;
    rows[lvlIdx].style.display = 'block';
    const s = selects[lvlIdx];
    s.innerHTML = '<option value="">– laden… –</option>';
    s.disabled  = true;

    try {
      const resp  = await fetch(lvl.fetchUrl(params));
      const items = await resp.json();
      s.innerHTML = '<option value="">– wählen –</option>';
      if (!Array.isArray(items)) {
        s.innerHTML = `<option value="">⚠︎${(items.error || 'Fehler').substring(0, 60)}</option>`;
        return;
      }
      // Levels 0 (Project) and 1 (Pool) use name as value; levels 2+ use ID
      const useNameAsValue = lvlIdx < 2;
      items.forEach(item => {
        const o = document.createElement('option');
        o.value       = useNameAsValue ? item.name : item.id;
        o.textContent = item.name + (item.description ? ` — ${item.description}` : '');
        s.appendChild(o);
      });
      s.disabled = false;

      // Restore previously selected value and auto-cascade
      const savedVal = sel[TH_SEL_KEYS[lvlIdx]];
      if (savedVal) {
        s.value = savedVal;
        if (s.value === savedVal) {
          if (lvlIdx === 5) {
            idBadge.textContent  = `CG-ID: ${savedVal}`;
            idBadge.style.display = 'block';
          } else {
            await loadLevel(lvlIdx + 1, sel);
          }
        }
      }
    } catch (_) {
      s.innerHTML = '<option value="">TARDIS nicht erreichbar</option>';
    }
  }

  // Build all rows upfront (hidden)
  TH_LEVELS.forEach((lvl, idx) => {
    const row = document.createElement('div');
    row.className    = 'th-row';
    row.style.display = 'none';

    const lbl = document.createElement('label');
    lbl.className   = 'cp-label';
    lbl.textContent = lvl.label;
    row.appendChild(lbl);

    const s = document.createElement('select');
    s.className = 'th-select';
    s.disabled  = true;
    s.addEventListener('change', async () => {
      const val = s.value;
      if (!val) return;

      // Persist intermediate selection in nd.config so it survives save/load
      if (idx < 5) { sel[TH_SEL_KEYS[idx]] = val; nd.config[TH_CFG_KEYS[idx]] = val; }
      if (idx === 5) {
        sel.channelgroup_id  = val;
        nd.config[field.key] = val;
        idBadge.textContent  = `CG-ID: ${val}`;
        idBadge.style.display = 'block';
        updateStatusDot(nd.id);
        return;
      }
      // Load next level
      await loadLevel(idx + 1, sel);
    });

    row.appendChild(s);
    selects.push(s);
    rows.push(row);
    widget.appendChild(row);
  });

  widget.appendChild(idBadge);
  container.appendChild(widget);

  // Start loading first level (will auto-cascade if saved values exist)
  loadLevel(0, sel);
}

// ── Output render helpers ─────────────────────────────────────────

function renderOutputCert(output) {
  const wrap = document.createElement('div');
  wrap.style.cssText = 'display:flex;flex-direction:column;gap:12px;padding:8px 0;';
  // Download button
  const fileCount = (output.files && output.files.length) ? output.files.length : 1;
  const btn = document.createElement('a');
  btn.href = output.download_url;
  btn.download = '';
  btn.className = 'flow-modal-btn primary';
  btn.style.cssText = 'display:inline-block;text-decoration:none;width:fit-content;padding:8px 20px;';
  btn.textContent = `⬇ Zertifikate herunterladen (${fileCount} Datei${fileCount !== 1 ? 'en' : ''})`;
  wrap.appendChild(btn);
  // Summary
  const info = document.createElement('div');
  info.style.cssText = 'font-size:10px;color:rgba(255,255,255,0.45);font-family:monospace;';
  const ratingsDisplay = Array.isArray(output.ratings) ? output.ratings.join(', ') : (output.ratings || output.rating || '—');
  info.innerHTML =
    `<div>ESN: <span style="color:#ffffff">${output.esn || '—'}</span></div>` +
    `<div>Ratings: <span style="color:#ffffff">${ratingsDisplay}</span></div>` +
    `<div>Platzhalter: ${output.placeholders}</div>`;
  if (output.files && output.files.length) {
    const ul = document.createElement('div');
    ul.style.cssText = 'margin-top:4px;';
    output.files.forEach(f => {
      const li = document.createElement('div');
      li.style.color = 'rgba(255,255,255,0.5)';
      li.textContent = '  ↳ ' + f;
      ul.appendChild(li);
    });
    info.appendChild(ul);
  }
  wrap.appendChild(info);
  return wrap;
}

function renderOutputCipaCert(output) {
  const wrap = document.createElement('div');
  wrap.style.cssText = 'display:flex;flex-direction:column;gap:12px;padding:8px 0;';

  // Download button
  const btn = document.createElement('a');
  btn.href = output.download_url;
  btn.download = '';
  btn.className = 'flow-modal-btn primary';
  btn.style.cssText = 'display:inline-block;text-decoration:none;width:fit-content;padding:8px 20px;';
  btn.textContent = '⬇ CIPA Zertifikat herunterladen';
  wrap.appendChild(btn);

  // Summary
  const info = document.createElement('div');
  info.style.cssText = 'font-size:10px;color:rgba(255,255,255,0.45);font-family:monospace;line-height:1.8;';
  info.innerHTML =
    `<div>ESN: <span style="color:#ffffff">${output.esn||'—'}</span></div>` +
    `<div>Trim Before: <span style="color:#ffffff">${output.trim_before||'—'}</span></div>` +
    `<div>Trim After: <span style="color:#ffffff">${output.trim_after||'—'}</span></div>`;
  if (output.files && output.files.length) {
    output.files.forEach(f => {
      const li = document.createElement('div');
      li.style.color = 'rgba(255,255,255,0.5)';
      li.textContent = '  ↳ ' + f;
      info.appendChild(li);
    });
  }
  wrap.appendChild(info);

  // Debug trace (collapsible)
  if (output._debug) {
    const dbg = output._debug;
    const sp = dbg.special_roles || {};
    const rowsHtml = (dbg.mapping_resolved||[]).map(r => {
      const ok = r.found;
      const icon = ok ? '<span style="color:#22c55e">✓</span>' : '<span style="color:#ef4444">✗</span>';
      const sc = r.scan_code ? ` <small style="opacity:.5">[${r.scan_code}]</small>` : '';
      return `<tr>
        <td style="padding:2px 5px">${icon}</td>
        <td style="padding:2px 5px;opacity:.7">${r.source}</td>
        <td style="padding:2px 5px">${r.source_field}${sc}</td>
        <td style="padding:2px 5px;opacity:.7">${r.placeholder}</td>
        <td style="padding:2px 5px;opacity:.7">${r.role}</td>
        <td style="padding:2px 5px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
            title="${r.value}">${r.value||'—'}</td>
      </tr>`;
    }).join('');
    const keysHtml = (dbg.flat_meta_keys||[]).map(k=>`<code style="background:rgba(255,255,255,0.08);padding:1px 3px;border-radius:2px;font-size:9px">${k}</code>`).join(' ');

    const details = document.createElement('details');
    details.style.cssText = 'font-size:11px;margin-top:4px;';
    details.innerHTML = `<summary style="cursor:pointer;color:rgba(255,255,255,0.4);user-select:none;padding:4px 0">
        🔍 Debug: Mapping Trace (${(dbg.mapping_resolved||[]).length} Einträge)
      </summary>
      <div style="margin-top:6px;padding:8px;background:rgba(0,0,0,0.3);border-radius:4px;overflow:auto">
        <div style="margin-bottom:6px;font-size:10px;color:rgba(255,255,255,0.4)">
          <b>Special Roles:</b>
          trim_before=<code style="color:#fff">${sp.trim_before||'—'}</code>
          &nbsp;trim_after=<code style="color:#fff">${sp.trim_after||'—'}</code>
          &nbsp;engine_config=<code style="color:#fff">${sp.engine_config||'—'}</code>
          &nbsp;pmux=<code style="color:#fff">${sp.pmux||'—'}</code>
        </div>
        <table style="width:100%;border-collapse:collapse;font-size:10px;font-family:monospace">
          <thead><tr style="color:rgba(255,255,255,0.3);border-bottom:1px solid rgba(255,255,255,0.1)">
            <th style="padding:2px 5px"></th>
            <th style="padding:2px 5px;text-align:left">Source</th>
            <th style="padding:2px 5px;text-align:left">Field</th>
            <th style="padding:2px 5px;text-align:left">Placeholder</th>
            <th style="padding:2px 5px;text-align:left">Role</th>
            <th style="padding:2px 5px;text-align:left">Value</th>
          </tr></thead>
          <tbody>${rowsHtml}</tbody>
        </table>
        <div style="margin-top:8px;font-size:9px;color:rgba(255,255,255,0.3)">
          <b>flat_meta keys (${(dbg.flat_meta_keys||[]).length}):</b><br>${keysHtml}
        </div>
      </div>`;
    wrap.appendChild(details);
  }

  return wrap;
}

function renderOutputPdf(output) {
  const wrap = document.createElement('div');
  wrap.style.cssText = 'display:flex;flex-direction:column;gap:12px;padding:8px 0;';

  const btn = document.createElement('a');
  btn.href = output.download_url;
  btn.download = output.filename || 'report.pdf';
  btn.className = 'flow-modal-btn primary';
  btn.style.cssText = 'display:inline-flex;align-items:center;gap:8px;text-decoration:none;width:fit-content;padding:8px 20px;';
  btn.innerHTML = '⬇ PDF herunterladen';
  wrap.appendChild(btn);

  const info = document.createElement('div');
  info.style.cssText = 'font-size:10px;color:rgba(255,255,255,0.45);font-family:monospace;line-height:1.7;';
  info.innerHTML =
    `<div>Datei: <span style="color:#ffffff">${output.filename || '—'}</span></div>` +
    `<div>Titel: <span style="color:#ffffff">${output.title || '—'}</span></div>` +
    `<div>ESN: <span style="color:#ffffff">${output.esn || '—'}</span>` +
    (output.wo ? `&nbsp;&nbsp;WO: <span style="color:#ffffff">${output.wo}</span>` : '') + `</div>` +
    `<div>Sektionen: <span style="color:#ffffff">${output.sections}</span></div>`;
  wrap.appendChild(info);

  // Auto-trigger download
  setTimeout(() => btn.click(), 120);
  return wrap;
}

function renderOutputJson(output) {
  const pre = document.createElement('pre');
  pre.className = 'cp-output-pre';
  pre.textContent = JSON.stringify(output, null, 2);
  return pre;
}

function renderOutputTable(output) {
  let cols, allRows;
  if (output && Array.isArray(output.columns) && Array.isArray(output.rows)) {
    cols = output.columns;
    allRows = output.rows;
  } else if (Array.isArray(output) && output.length > 0 && typeof output[0] === 'object') {
    cols = Object.keys(output[0]);
    allRows = output.map(r => cols.map(c => r[c] != null ? r[c] : ''));
  } else {
    return renderOutputJson(output);
  }

  // Normalise to array-of-arrays
  allRows = allRows.map(r => Array.isArray(r) ? r : cols.map((c,i) => r[c] != null ? r[c] : ''));

  const statusIdx      = cols.indexOf('__status__');
  const violationsIdx  = cols.indexOf('__violations__');
  const allVisibleIdxs = cols.map((c,i) => i).filter(i => !cols[i].startsWith('__'));

  // ── outer container
  const outer = document.createElement('div');
  outer.style.cssText = 'display:flex;flex-direction:column;flex:1;overflow:hidden;min-height:0;';

  // ── search bar
  const searchBar = document.createElement('div');
  searchBar.className = 'od-search-bar';
  const searchInput = document.createElement('input');
  searchInput.className = 'od-search-input';
  searchInput.placeholder = 'Parametername suchen …';
  searchInput.type = 'text';
  const countSpan = document.createElement('span');
  countSpan.className = 'od-search-count';
  searchBar.appendChild(searchInput);
  searchBar.appendChild(countSpan);
  outer.appendChild(searchBar);

  // ── table wrap
  const wrap = document.createElement('div');
  wrap.className = 'cp-output-table-wrap';
  wrap.style.flex = '1';
  const tbl = document.createElement('table');
  tbl.className = 'cp-output-table';
  const thead = tbl.createTHead();
  const hr    = thead.insertRow();
  const tbody = tbl.createTBody();
  wrap.appendChild(tbl);
  outer.appendChild(wrap);

  // ── info line
  const infoLine = document.createElement('div');
  infoLine.className = 'cp-output-info';
  outer.appendChild(infoLine);

  // ── state
  const MAX_INIT_COLS = 100;
  let sortState  = { colIdx: null, dir: 'asc' };
  let activeIdxs = allVisibleIdxs.slice();
  let debounce   = null;

  // ── rebuild entire table (header + body) with only activeIdxs columns
  function rebuildTable() {
    // ── header
    hr.innerHTML = '';
    if (statusIdx >= 0) {
      const th = document.createElement('th');
      th.textContent = '●';
      th.style.cssText = 'width:22px;text-align:center;padding:3px 4px;cursor:default;';
      hr.appendChild(th);
    }
    activeIdxs.forEach(i => {
      const th    = document.createElement('th');
      const arrow = document.createElement('span');
      arrow.className = 'sort-arrow';
      const isSort = sortState.colIdx === i;
      arrow.textContent = isSort ? (sortState.dir === 'asc' ? '▲' : '▼') : '▲';
      if (isSort) th.classList.add(sortState.dir === 'asc' ? 'sort-asc' : 'sort-desc');
      th.appendChild(document.createTextNode(cols[i]));
      th.appendChild(arrow);
      th.addEventListener('click', () => {
        sortState = sortState.colIdx === i
          ? { colIdx: i, dir: sortState.dir === 'asc' ? 'desc' : 'asc' }
          : { colIdx: i, dir: 'asc' };
        rebuildTable();
      });
      hr.appendChild(th);
    });

    // ── sort rows (only if sort col still visible)
    let sortedRows = allRows;
    if (sortState.colIdx !== null) {
      if (activeIdxs.includes(sortState.colIdx)) {
        const si  = sortState.colIdx;
        const dir = sortState.dir === 'asc' ? 1 : -1;
        sortedRows = allRows.slice().sort((a, b) => {
          const av = a[si], bv = b[si];
          const an = parseFloat(av), bn = parseFloat(bv);
          if (!isNaN(an) && !isNaN(bn)) return (an - bn) * dir;
          return String(av).localeCompare(String(bv)) * dir;
        });
      } else {
        sortState.colIdx = null;   // sort col filtered out → reset
      }
    }

    // ── body: only cells for activeIdxs (no hidden elements)
    tbody.innerHTML = '';
    let passCount = 0, failCount = 0;
    sortedRows.forEach(arr => {
      const status     = statusIdx     >= 0 ? arr[statusIdx]     : null;
      const violations = violationsIdx >= 0 ? arr[violationsIdx] : '';
      const failedCols = new Set(
        (violations || '').split('; ')
          .map(v => { const m = v.match(/^([^=]+)=/); return m ? m[1].trim() : null; })
          .filter(Boolean)
      );
      const tr = tbody.insertRow();
      if      (status === 'PASS') { tr.style.background = 'rgba(255,255,255,0.08)'; passCount++; }
      else if (status === 'FAIL') { tr.style.background = 'rgba(192,57,43,0.12)';  failCount++; }
      if (statusIdx >= 0) {
        const td = tr.insertCell();
        td.textContent = status === 'PASS' ? '✓' : (status === 'FAIL' ? '✗' : '');
        td.style.cssText = `text-align:center;font-weight:bold;color:${status==='PASS'?'#22c55e':'#ef4444'};`;
        if (violations) td.title = violations;
      }
      activeIdxs.forEach(i => {
        const td = tr.insertCell();
        td.textContent = arr[i];
        if (status === 'FAIL' && failedCols.has(cols[i])) {
          td.style.cssText = 'background:rgba(239,68,68,0.18);color:#fca5a5;font-weight:600;';
        }
      });
    });

    // PASS/FAIL summary
    if (statusIdx >= 0) {
      infoLine.innerHTML =
        `<span style="color:#22c55e">✓ ${passCount} PASS</span> &nbsp; ` +
        `<span style="color:#ef4444">✗ ${failCount} FAIL</span>`;
    } else {
      infoLine.textContent = '';
    }
  }

  // ── refresh: filter columns + update count + rebuild (debounced from input)
  function refresh() {
    const q     = searchInput.value.trim().toLowerCase();
    const total = allVisibleIdxs.length;
    const truncated = output.total_rows && output.total_rows > allRows.length;
    const rowInfo = truncated
      ? ` · ${allRows.length} von ${output.total_rows} Zeilen`
      : ` · ${allRows.length} Zeilen`;

    if (q) {
      activeIdxs = allVisibleIdxs.filter(i => cols[i].toLowerCase().includes(q));
      countSpan.textContent = `${activeIdxs.length} / ${total} Spalten${rowInfo}`;
    } else {
      // no search: cap to MAX_INIT_COLS for fast initial render
      activeIdxs = total > MAX_INIT_COLS
        ? allVisibleIdxs.slice(0, MAX_INIT_COLS)
        : allVisibleIdxs.slice();
      countSpan.textContent = total > MAX_INIT_COLS
        ? `erste ${MAX_INIT_COLS} von ${total} Spalten${rowInfo} — suchen zum Filtern`
        : `${total} Spalten${rowInfo}`;
    }

    if (sortState.colIdx !== null && !activeIdxs.includes(sortState.colIdx))
      sortState.colIdx = null;

    rebuildTable();
  }

  // B) 150ms debounce on keystrokes
  searchInput.addEventListener('input', () => {
    clearTimeout(debounce);
    debounce = setTimeout(refresh, 150);
  });

  refresh();
  return outer;
}

function renderOutputChart(output) {
  let cols, rows;
  if (output && Array.isArray(output.columns) && Array.isArray(output.rows)) {
    cols = output.columns; rows = output.rows;
  } else { return renderOutputJson(output); }

  const numericColIdxs = cols.map((_,i) => i)
    .filter(i => rows.some(r => r[i] !== '' && !isNaN(parseFloat(r[i]))))
    .slice(0, 5);
  if (numericColIdxs.length === 0) return renderOutputJson(output);

  const W = 800, H = 180, PAD = { top: 10, right: 12, bottom: 24, left: 40 };
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
  svg.style.cssText = 'width:100%;flex:1;display:block;border-radius:3px;min-height:80px;';

  const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
  bg.setAttribute('width', W); bg.setAttribute('height', H); bg.setAttribute('fill', '#0a0d11');
  svg.appendChild(bg);

  // Grid lines
  [0.25,0.5,0.75].forEach(t => {
    const y = PAD.top + t * (H - PAD.top - PAD.bottom);
    const gl = document.createElementNS('http://www.w3.org/2000/svg', 'line');
    gl.setAttribute('x1', PAD.left); gl.setAttribute('x2', W - PAD.right);
    gl.setAttribute('y1', y); gl.setAttribute('y2', y);
    gl.setAttribute('stroke', 'rgba(255,255,255,0.06)'); gl.setAttribute('stroke-width', '0.5');
    svg.appendChild(gl);
  });

  const colors = ['#ffffff','#0ea5e9','#f59e0b','#ec4899','#22c55e'];
  const n = rows.length;
  const xScale = i => PAD.left + (n > 1 ? (i / (n - 1)) : 0.5) * (W - PAD.left - PAD.right);

  numericColIdxs.forEach((ci, li) => {
    const vals = rows.map(r => parseFloat(r[ci]));
    const validVals = vals.filter(v => !isNaN(v));
    if (!validVals.length) return;
    const min = Math.min(...validVals), max = Math.max(...validVals);
    const yScale = v => PAD.top + (1 - (v - min) / (max - min || 1)) * (H - PAD.top - PAD.bottom);

    const pts = rows.map((r, i) => {
      const v = parseFloat(r[ci]);
      return isNaN(v) ? null : `${xScale(i).toFixed(1)},${yScale(v).toFixed(1)}`;
    }).filter(Boolean).join(' ');

    const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
    pl.setAttribute('points', pts);
    pl.setAttribute('fill', 'none');
    pl.setAttribute('stroke', colors[li % colors.length]);
    pl.setAttribute('stroke-width', '1.2');
    pl.setAttribute('opacity', '0.9');
    svg.appendChild(pl);

    // Legend label
    const lbl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
    lbl.setAttribute('x', PAD.left + li * 52); lbl.setAttribute('y', H - 6);
    lbl.setAttribute('fill', colors[li % colors.length]); lbl.setAttribute('font-size', '7');
    lbl.setAttribute('font-family', 'monospace');
    lbl.textContent = cols[ci].substring(0, 9);
    svg.appendChild(lbl);
  });

  const wrap = document.createElement('div');
  wrap.style.cssText = 'margin-top:4px;border:1px solid rgba(255,255,255,0.2);border-radius:3px;overflow:hidden;';
  wrap.appendChild(svg);
  return wrap;
}

async function pfSyncHbToTardis(hbNodeId) {
  const hbNd = nodes.find(n => n.id === hbNodeId); if (!hbNd) return;
  const esn = hbNd.config.hb_esn || '';
  const wo  = hbNd.config.hb_wo  || '';
  if (!esn && !wo) return;
  // Paired TARDIS Query finden — zwei Pfade:
  // Path A: HB Read direkt → TARDIS Query (hb_sync Port)
  // Path B: HB Read → Cert Build (Port 2) → TARDIS Query (Port 1)
  let tardisNd = null;

  const directConn = connections.find(c =>
    c.fromNodeId === hbNodeId &&
    nodes.find(n => n.id === c.toNodeId)?.typeId === 'tardis_query'
  );
  if (directConn) {
    tardisNd = nodes.find(n => n.id === directConn.toNodeId);
  }

  if (!tardisNd) {
    const cbConn = connections.find(c => c.fromNodeId === hbNodeId && c.toPortIdx === 2);
    if (cbConn) {
      const tardisConn = connections.find(c => c.toNodeId === cbConn.toNodeId && c.toPortIdx === 1);
      if (tardisConn) tardisNd = nodes.find(n => n.id === tardisConn.fromNodeId);
    }
  }

  if (!tardisNd || tardisNd.typeId !== 'tardis_query')
    return _pfSyncFail(hbNodeId, 'Kein TARDIS Query verbunden (direkt oder via Cert Build)');

  // 1. Test finden (alle Projekte + Pools)
  const match = await fetch(`/api/tardis/find_test?esn=${encodeURIComponent(esn)}&wo=${encodeURIComponent(wo)}`)
    .then(r => r.json()).catch(() => null);
  if (!match || match.error) return _pfSyncFail(hbNodeId, match?.error || 'Test nicht gefunden');

  // 2. TestStep finden (enthält "LUD")
  const teststeps = await fetch(`/api/tardis/teststeps?test_id=${encodeURIComponent(match.test_id)}`)
    .then(r => r.json()).catch(() => []);
  const teststep = teststeps.find(s => s.name && s.name.includes('LUD')) || teststeps[0];
  if (!teststep) return _pfSyncFail(hbNodeId, 'Kein TestStep mit "LUD" gefunden');

  // 3. Measurement finden (enthält "absolute")
  const measurements = await fetch(`/api/tardis/measurements?teststep_id=${encodeURIComponent(teststep.id)}`)
    .then(r => r.json()).catch(() => []);
  const measurement = measurements.find(m => m.name && m.name.toLowerCase().includes('absolute')) || measurements[0];
  if (!measurement) return _pfSyncFail(hbNodeId, 'Kein Measurement mit "absolute" gefunden');

  // 4. ChannelGroup — immer nur eine, erste nehmen
  const channelgroups = await fetch(`/api/tardis/channelgroups?measurement_id=${encodeURIComponent(measurement.id)}`)
    .then(r => r.json()).catch(() => []);
  const channelgroup = channelgroups[0];
  if (!channelgroup) return _pfSyncFail(hbNodeId, 'Keine ChannelGroup gefunden');

  // Alle Ebenen in TARDIS Node Config setzen
  tardisNd.config.th_project        = match.project;
  tardisNd.config.th_pool           = match.pool;
  tardisNd.config.th_test_id        = match.test_id;
  tardisNd.config.th_teststep_id    = teststep.id;
  tardisNd.config.th_measurement_id = measurement.id;
  tardisNd.config.channelgroupId    = channelgroup.id;
  tardisNd.config._synced_from_hb   = match.test_name;
  hbNd.config._sync_target          = tardisNd.id;

  // Status-Badge im HB-Panel aktualisieren
  const statusEl = document.getElementById(`hb-sync-${hbNodeId}`);
  if (statusEl) {
    statusEl.textContent = `🔗 ${match.test_name}  ▸  CG ${channelgroup.id}`;
    statusEl.className = 'hb-sync-status ok';
  }
  // Config-Panel neu rendern falls TARDIS Node gerade offen ist
  if (configPanel?.dataset.nodeId == tardisNd.id) renderConfigPanel(tardisNd.id);
  updateStatusDot(tardisNd.id);
  updateStatusDot(hbNodeId);
}

// Sync-Fehler sichtbar machen
function _pfSyncFail(hbNodeId, msg) {
  const statusEl = document.getElementById(`hb-sync-${hbNodeId}`);
  if (statusEl) { statusEl.textContent = `✗ ${msg||'Kein Match'}`; statusEl.className = 'hb-sync-status fail'; }
}

function renderConfigPanel(nodeId) {
  const nd   = nodes.find(n => n.id === nodeId);
  if (!nd) return;
  const type = NODE_TYPES[nd.typeId];
  cpIcon.textContent  = type.icon;
  cpIcon.style.color  = type.color;
  cpTitle.textContent = type.label;
  cpBody.innerHTML    = '';

  // Fehler oder Debug-Output nach Run anzeigen
  if (nd.output && nd.output.error) {
    const errBox = document.createElement('pre');
    errBox.style.cssText = 'background:#2a1010;color:#ef4444;padding:10px 12px;border-radius:6px;font-size:11px;white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:200px;overflow:auto;';
    errBox.textContent = '⚠︎' + nd.output.error;
    cpBody.appendChild(errBox);
  } else if (nd.output && nd.output.__debug__) {
    const dbgBox = document.createElement('pre');
    dbgBox.style.cssText = 'background:#0a1a10;color:#22c55e;padding:8px 12px;border-radius:6px;font-size:11px;white-space:pre-wrap;margin-bottom:12px;';
    dbgBox.textContent = '✓ ' + JSON.stringify(nd.output.__debug__, null, 2);
    cpBody.appendChild(dbgBox);
  } else if (nd.typeId === 'branch' && nd.output && nd.output.result !== undefined) {
    const met  = nd.output.result === 'true';
    const brBox = document.createElement('div');
    brBox.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:6px;margin-bottom:12px;font-size:11px;font-weight:600;'
      + (met ? 'background:rgba(34,197,94,0.10);color:#22c55e;border:1px solid rgba(34,197,94,0.25);'
             : 'background:rgba(239,68,68,0.10);color:#ef4444;border:1px solid rgba(239,68,68,0.25);');
    brBox.innerHTML = (met ? '⑂ → <b>true</b>' : '⑂ → <b>false</b>')
      + `<span style="font-weight:400;opacity:.7;margin-left:4px;font-size:10px;">(${nd.output.condition})</span>`;
    cpBody.appendChild(brBox);
  }

  if (!type.fields || type.fields.length === 0) {
    const emp = document.createElement('div');
    emp.className   = 'cp-empty';
    emp.textContent = 'Keine Konfiguration';
    cpBody.appendChild(emp);
    return;
  }

  for (const field of type.fields) {
    const wrap  = document.createElement('div');
    wrap.className = 'cp-field';

    const lbl   = document.createElement('label');
    lbl.className = 'cp-label';
    lbl.textContent = field.label;
    if (field.required) {
      const star = document.createElement('span');
      star.className   = 'cp-req';
      star.textContent = '*';
      lbl.appendChild(star);
    }
    wrap.appendChild(lbl);

    if (field.type === 'tardis-hierarchy') {
      renderTardisHierarchy(field, nd, wrap);
      cpBody.appendChild(wrap);
      continue;
    }

    if (field.type === 'hb-order-select') {
      const sel = document.createElement('select');
      sel.className = 'cp-select';
      sel.innerHTML = '<option value="">– laden… –</option>';
      fetch('/api/hb/orders').then(r => r.json()).then(data => {
        if (data && data.error) throw new Error(data.error);
        const orders = Array.isArray(data) ? data : [];
        sel.innerHTML = '<option value="">– Order wählen –</option>';
        orders.forEach(o => {
          const opt = document.createElement('option');
          const oid   = o.identifier || o.Identifier || o.id || '';
          let   esn   = o.esn || o.ESN || o.engine_serial_number || '';
          let   build = o.buildnumber || o.build_number || o.BuildNumber || '';
          const label = o.title || o.name || esn || String(oid);
          // Titel-Format "960221 #L14141" → ESN vor #, WO nach #
          if ((!esn || !build) && label.includes('#')) {
            const parts = label.split('#');
            if (!esn)   esn   = parts[0].trim();
            if (!build) build = parts[1].trim();
          }
          opt.value = String(oid);
          opt.textContent = label + (build && !label.includes('#'+build) ? ` #${build}` : '');
          opt.dataset.esn = esn;
          opt.dataset.wo  = build;
          if (opt.value === nd.config[field.key]) opt.selected = true;
          sel.appendChild(opt);
        });
      }).catch(e => {
        sel.innerHTML = `<option value="">Fehler: ${e.message}</option>`;
      });
      sel.addEventListener('change', () => {
        nd.config[field.key] = sel.value;
        const selOpt = sel.options[sel.selectedIndex];
        nd.config.hb_esn = selOpt?.dataset.esn || '';
        nd.config.hb_wo  = selOpt?.dataset.wo  || '';
        nd.config._synced_from_hb = '';
        updateStatusDot(nd.id);
        const statusEl = document.getElementById(`hb-sync-${nd.id}`);
        if (statusEl) { statusEl.textContent = '⏳ Suche TARDIS-Test…'; statusEl.className = 'hb-sync-status pending'; }
        pfSyncHbToTardis(nd.id);
      });
      wrap.appendChild(sel);
      // Sync-Status-Anzeige
      const syncDiv = document.createElement('div');
      syncDiv.id = `hb-sync-${nd.id}`;
      syncDiv.className = 'hb-sync-status' + (nd.config._synced_from_hb ? ' ok' : '');
      syncDiv.textContent = nd.config._synced_from_hb ? `🔗 ${nd.config._synced_from_hb}` : '';
      wrap.appendChild(syncDiv);
      cpBody.appendChild(wrap);
      continue;
    }

    if (field.type === 'template-steps-select') {
      const sel = document.createElement('select');
      sel.className = 'cp-select';
      sel.innerHTML = '<option value="">– laden… –</option>';
      const mode = nd.config['mode'] || 'boost';
      const url  = mode === 'dashboard' ? '/api/dashboard-templates' : '/api/hb/templates';
      fetch(url).then(r => r.json()).then(data => {
        if (data && data.error) throw new Error(data.error);
        const list = Array.isArray(data) ? data : (data.templates || []);
        sel.innerHTML = '<option value="">– Template wählen –</option>';
        list.forEach(t => {
          const opt = document.createElement('option');
          opt.value = String(t.id || t.identifier || t.title || '');
          opt.textContent = t.title || t.name || opt.value;
          if (opt.value === nd.config[field.key]) opt.selected = true;
          sel.appendChild(opt);
        });
      }).catch(e => { sel.innerHTML = `<option value="">Fehler: ${e.message}</option>`; });
      sel.addEventListener('change', () => { nd.config[field.key] = sel.value; updateStatusDot(nd.id); });
      wrap.appendChild(sel);
      cpBody.appendChild(wrap);
      continue;
    }

    if (field.type === 'recipe-select') {
      const sel = document.createElement('select');
      sel.className = 'cp-select';
      sel.innerHTML = '<option value="">– laden… –</option>';
      fetch('/api/recipes').then(r => r.json()).then(recipes => {
        sel.innerHTML = '<option value="">– wählen –</option>';
        recipes.forEach(rec => {
          const o = document.createElement('option');
          o.value = rec.engine_type || '';
          o.textContent = rec.engine_type || '(unbekannt)';
          if (o.value === nd.config[field.key]) o.selected = true;
          sel.appendChild(o);
        });
      }).catch(() => { sel.innerHTML = '<option value="">recipes.json fehlt</option>'; });
      sel.addEventListener('change', () => {
        nd.config[field.key] = sel.value;
        updateStatusDot(nd.id);
      });
      wrap.appendChild(sel);
      cpBody.appendChild(wrap);
      continue;
    }

    if (field.type === 'recipe-rating-select') {
      // Find connected recipe node on port 0
      const recipeConn = connections.find(c => c.toNodeId === nd.id && c.toPortIdx === 0);
      const recipeNode = recipeConn ? nodes.find(n => n.id === recipeConn.fromNodeId) : null;
      const engineType = recipeNode ? recipeNode.config.engineType : null;
      if (!engineType) {
        const hint = document.createElement('div');
        hint.className = 'cp-label'; hint.style.fontStyle = 'italic';
        hint.textContent = 'Recipe-Node (Port 0) verbinden für Rating-Auswahl';
        wrap.appendChild(hint);
      } else {
        const sel = document.createElement('select');
        sel.className = 'cp-select';
        sel.innerHTML = '<option value="">– laden… –</option>';
        fetch('/api/recipes').then(r => r.json()).then(recipes => {
          const rec = recipes.find(r => r.engine_type === engineType);
          sel.innerHTML = '<option value="">(erstes Rating)</option>';
          (rec ? rec.ratings || [] : []).forEach(rt => {
            const o = document.createElement('option');
            o.value = rt.name || ''; o.textContent = rt.name || '?';
            if (o.value === nd.config[field.key]) o.selected = true;
            sel.appendChild(o);
          });
        }).catch(() => { sel.innerHTML = '<option value="">Fehler beim Laden</option>'; });
        sel.addEventListener('change', () => { nd.config[field.key] = sel.value; });
        wrap.appendChild(sel);
      }
      cpBody.appendChild(wrap);
      continue;
    }

    if (field.type === 'recipe-scan-select') {
      // Find connected recipe node (if any)
      const recipeConn = connections.find(c => c.toNodeId === nd.id && c.toPortIdx === 1);
      const recipeNode = recipeConn ? nodes.find(n => n.id === recipeConn.fromNodeId) : null;
      const engineType = recipeNode ? recipeNode.config.engineType : null;
      if (!engineType) {
        const hint = document.createElement('div');
        hint.className = 'cp-label';
        hint.style.fontStyle = 'italic';
        hint.textContent = 'Recipe-Node verbinden für Scan-Code-Auswahl';
        wrap.appendChild(hint);
      } else {
        const sel = document.createElement('select');
        sel.className = 'cp-select';
        sel.innerHTML = '<option value="">– laden… –</option>';
        fetch('/api/recipes').then(r => r.json()).then(recipes => {
          const rec = recipes.find(r => r.engine_type === engineType);
          sel.innerHTML = '<option value="">– Scan-Code wählen –</option>';
          (rec ? rec.required_scans || [] : []).forEach(s => {
            const o = document.createElement('option');
            o.value = s.code || '';
            o.textContent = s.code + (s.label ? ` — ${s.label}` : '');
            if (o.value === nd.config[field.key]) o.selected = true;
            sel.appendChild(o);
          });
          sel.disabled = false;
        }).catch(() => { sel.innerHTML = '<option value="">Fehler beim Laden</option>'; });
        sel.addEventListener('change', () => {
          nd.config[field.key] = sel.value;
          updateStatusDot(nd.id);
        });
        wrap.appendChild(sel);
      }
      cpBody.appendChild(wrap);
      continue;
    }

    if (field.type === 'engine-mapping-select') {
      const sel = document.createElement('select'); sel.className = 'cp-select';
      sel.innerHTML = '<option value="">– laden… –</option>';
      fetch('/api/engine-mappings').then(r => r.json()).then(data => {
        sel.innerHTML = '<option value="">– Auto-Detect –</option>';
        (Array.isArray(data) ? data : []).forEach(m => {
          const opt = document.createElement('option');
          opt.value = m.id || '';
          opt.textContent = (m.engine_type || opt.value) + (m.pdf_prefix ? ` (${m.pdf_prefix})` : '');
          if (opt.value === nd.config[field.key]) opt.selected = true;
          sel.appendChild(opt);
        });
      }).catch(e => { sel.innerHTML = `<option value="">Fehler: ${e.message}</option>`; });
      sel.addEventListener('change', () => { nd.config[field.key] = sel.value; updateStatusDot(nd.id); });
      wrap.appendChild(sel);
      cpBody.appendChild(wrap);
      continue;
    }

    if (field.type === 'step-template-select') {
      const sel = document.createElement('select'); sel.className = 'cp-select';
      sel.innerHTML = '<option value="">– laden… –</option>';
      fetch('/api/step-templates').then(r => r.json()).then(data => {
        sel.innerHTML = '<option value="">– Template wählen –</option>';
        (Array.isArray(data) ? data : []).forEach(t => {
          const opt = document.createElement('option');
          opt.value = t.id || '';
          const procCount = (t.procedures || []).length;
          opt.textContent = (t.name || opt.value) + ` (${procCount} Proc.)`;
          if (opt.value === nd.config[field.key]) opt.selected = true;
          sel.appendChild(opt);
        });
      }).catch(e => { sel.innerHTML = `<option value="">Fehler: ${e.message}</option>`; });
      sel.addEventListener('change', () => { nd.config[field.key] = sel.value; updateStatusDot(nd.id); });
      wrap.appendChild(sel);
      cpBody.appendChild(wrap);
      continue;
    }

    let input;
    if (field.type === 'select') {
      input = document.createElement('select');
      input.className = 'cp-select';
      for (const opt of field.options) {
        const o = document.createElement('option');
        o.value = opt; o.textContent = opt;
        input.appendChild(o);
      }
      input.value = nd.config[field.key] ?? field.options[0];
    } else if (field.type === 'code') {
      const hints = document.createElement('div');
      hints.className = 'code-hints';
      hints.textContent = 'data  ·  inputs[\'1\']  ·  pd (pandas)  ·  np (numpy)  →  result = …';
      wrap.appendChild(hints);
      input = document.createElement('textarea');
      input.className   = 'cp-textarea code-editor';
      input.placeholder = field.placeholder || '';
      input.value       = nd.config[field.key] ?? '';
    } else if (field.type === 'textarea') {
      input = document.createElement('textarea');
      input.className   = 'cp-textarea';
      input.placeholder = field.placeholder || '';
      input.value       = nd.config[field.key] ?? '';
    } else {
      input = document.createElement('input');
      input.className   = 'cp-input';
      input.type        = field.type === 'number' ? 'number' : 'text';
      input.placeholder = field.placeholder || '';
      input.value       = nd.config[field.key] ?? '';
    }

    input.addEventListener('input', () => {
      nd.config[field.key] = input.value;
      updateStatusDot(nodeId);
      if (nd.typeId === 'note') {
        const prev = nd.el.querySelector('.note-preview');
        if (prev) prev.textContent = input.value || '…';
      }
      if (nd.typeId === 'group') {
        nd.el._groupLabel = nd.config.label || '';
        nd.el._groupColor = nd.config.color || 'indigo';
        nd.el._groupW     = nd.config.width  || nd.el._groupW;
        nd.el._groupH     = nd.config.height || nd.el._groupH;
        nd.el.applyGroupStyle();
      }
    });
    wrap.appendChild(input);
    cpBody.appendChild(wrap);
  }

  // ── cert_build: Scan-Validierung + Rating×CertType Matrix ─────────
  if (nd.typeId === 'cert_build') {
    renderCertBuildPanel(nd, cpBody);
  }

  // ── pdf_compose: Section-Labels ────────────────────────────────────
  if (nd.typeId === 'pdf_compose') {
    renderPdfComposePanel(nd, cpBody);
  }

  // ── check: Recipe-Modus oder manuelle Regeln ────────────────────
  if (nd.typeId === 'check') {
    renderCheckPanel(nd, cpBody);
  }

  // ── scan_query: Scan-Code Mehrfachauswahl ───────────────────────
  if (nd.typeId === 'scan_query') {
    renderScanQueryPanel(nd, cpBody);
  }

}

// ── cert_build custom panel sections ─────────────────────────────────

function renderCertBuildPanel(nd, container) {
  // ── Port lookups ────────────────────────────────────────────────────
  const recipeConn = connections.find(c => c.toNodeId === nd.id && c.toPortIdx === 0);
  const recipeNode = recipeConn ? nodes.find(n => n.id === recipeConn.fromNodeId) : null;
  const engineType = recipeNode ? recipeNode.config.engineType : null;

  const tardisConn = connections.find(c => c.toNodeId === nd.id && c.toPortIdx === 1);
  const tardisNode = tardisConn ? nodes.find(n => n.id === tardisConn.fromNodeId) : null;
  const testId     = tardisNode ? (tardisNode.config.th_test_id || '') : '';

  if (!engineType) {
    const hint = document.createElement('div');
    hint.className = 'cp-label';
    hint.style.cssText = 'font-style:italic;margin-top:12px;opacity:0.55;';
    hint.textContent = 'Recipe-Node (Port 0) verbinden um Dokumententyp, Ratings und Scans zu konfigurieren.';
    container.appendChild(hint);
    return;
  }

  // Load recipe once, then build all sections
  fetch('/api/recipes').then(r => r.json()).then(recipes => {
    const rec = recipes.find(r => r.engine_type === engineType);
    if (!rec) {
      const err = document.createElement('div');
      err.className = 'cp-label'; err.style.color = 'var(--status-err,#e74c3c)';
      err.textContent = `Recipe "${engineType}" nicht gefunden.`;
      container.appendChild(err);
      return;
    }
    const ratings   = rec.ratings   || [];
    const certTypes = rec.cert_types || [];

    // ── Section A: Dokumententyp (global, ein Dropdown) ──────────────
    const docSec = document.createElement('div');
    docSec.style.cssText = 'margin-top:14px;';
    const docLabel = document.createElement('div');
    docLabel.className = 'cp-label';
    docLabel.style.cssText = 'font-size:9px;letter-spacing:0.18em;text-transform:uppercase;margin-bottom:6px;';
    docLabel.textContent = 'DOKUMENTENTYP';
    docSec.appendChild(docLabel);

    if (!certTypes.length) {
      const noct = document.createElement('div');
      noct.className = 'cp-label'; noct.style.fontStyle = 'italic';
      noct.textContent = 'Keine Dokumenttypen im Recipe definiert.';
      docSec.appendChild(noct);
    } else {
      const ctSel = document.createElement('select');
      ctSel.className = 'cp-select';
      certTypes.forEach(ct => {
        const o = document.createElement('option');
        o.value = ct.name; o.textContent = ct.name;
        if (ct.name === nd.config.certTypeName) o.selected = true;
        ctSel.appendChild(o);
      });
      // Initialize default
      if (!nd.config.certTypeName && certTypes.length) nd.config.certTypeName = certTypes[0].name;
      ctSel.addEventListener('change', () => { nd.config.certTypeName = ctSel.value; });
      docSec.appendChild(ctSel);
    }
    container.appendChild(docSec);

    // ── Section B: Ratings (toggle chips) ────────────────────────────
    const ratingSec = document.createElement('div');
    ratingSec.style.cssText = 'margin-top:14px;';
    const ratingHeader = document.createElement('div');
    ratingHeader.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;';
    const ratingLabel = document.createElement('div');
    ratingLabel.className = 'cp-label';
    ratingLabel.style.cssText = 'font-size:9px;letter-spacing:0.18em;text-transform:uppercase;';
    ratingLabel.textContent = 'RATINGS';
    ratingHeader.appendChild(ratingLabel);

    if (!ratings.length) {
      ratingSec.appendChild(ratingHeader);
      const nor = document.createElement('div');
      nor.className = 'cp-label'; nor.style.fontStyle = 'italic';
      nor.textContent = 'Keine Ratings im Recipe definiert.';
      ratingSec.appendChild(nor);
    } else {
      // Initialize to empty (nothing pre-selected)
      if (!Array.isArray(nd.config.selected_ratings)) nd.config.selected_ratings = [];

      // Quick-select buttons
      const quickBtns = document.createElement('div');
      quickBtns.style.cssText = 'display:flex;gap:4px;';
      ['Alle','Keine'].forEach(label => {
        const btn = document.createElement('button');
        btn.textContent = label;
        btn.style.cssText = 'font-size:9px;padding:1px 7px;border-radius:10px;border:1px solid rgba(255,255,255,0.25);background:rgba(255,255,255,0.07);color:rgba(255,255,255,0.6);cursor:pointer;';
        btn.addEventListener('click', () => {
          nd.config.selected_ratings = label === 'Alle' ? ratings.map(r => r.name) : [];
          refreshChips();
        });
        quickBtns.appendChild(btn);
      });
      ratingHeader.appendChild(quickBtns);
      ratingSec.appendChild(ratingHeader);

      // Chip container
      const chipWrap = document.createElement('div');
      chipWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:5px;';

      function refreshChips() {
        chipWrap.innerHTML = '';
        ratings.forEach(rt => {
          const active = (nd.config.selected_ratings || []).includes(rt.name);
          const chip = document.createElement('button');
          chip.textContent = rt.name;
          chip.style.cssText = [
            'font-size:10px;padding:3px 10px;border-radius:12px;cursor:pointer;',
            'transition:all 0.15s;border:1px solid;white-space:nowrap;',
            active
              ? 'background:var(--accent,#6366f1);border-color:var(--accent,#6366f1);color:#fff;font-weight:600;'
              : 'background:rgba(255,255,255,0.05);border-color:rgba(255,255,255,0.2);color:rgba(255,255,255,0.55);'
          ].join('');
          chip.addEventListener('click', () => {
            if (!nd.config.selected_ratings) nd.config.selected_ratings = [];
            if (active) {
              nd.config.selected_ratings = nd.config.selected_ratings.filter(r => r !== rt.name);
            } else {
              nd.config.selected_ratings.push(rt.name);
            }
            refreshChips();
          });
          chipWrap.appendChild(chip);
        });
      }
      refreshChips();
      ratingSec.appendChild(chipWrap);
    }
    container.appendChild(ratingSec);

    // ── Section C: Scan-Validierung ───────────────────────────────────
    const scanSec = document.createElement('div');
    scanSec.style.cssText = 'margin-top:14px;';
    const scanLabel = document.createElement('div');
    scanLabel.className = 'cp-label';
    scanLabel.style.cssText = 'font-size:9px;letter-spacing:0.18em;text-transform:uppercase;margin-bottom:6px;';
    scanLabel.textContent = 'SCAN-VALIDIERUNG';
    scanSec.appendChild(scanLabel);

    if (!testId) {
      const hint = document.createElement('div');
      hint.className = 'cp-label'; hint.style.fontStyle = 'italic'; hint.style.opacity = '0.5';
      hint.textContent = 'TARDIS Query (Port 1) verbinden und Hierarchy bis zur Test-Ebene auswählen.';
      scanSec.appendChild(hint);
    } else {
      const tidInfo = document.createElement('div');
      tidInfo.style.cssText = 'font-size:9px;color:rgba(255,255,255,0.35);margin-bottom:6px;';
      tidInfo.textContent = 'Test-ID: ' + testId;
      scanSec.appendChild(tidInfo);

      const checkBtn = document.createElement('button');
      checkBtn.className = 'flow-modal-btn';
      checkBtn.textContent = '⟳ Scans prüfen';
      checkBtn.style.cssText = 'width:100%;margin-bottom:8px;font-size:10px;padding:6px 10px;';
      scanSec.appendChild(checkBtn);

      const scanTable = document.createElement('div');
      scanTable.id = 'cert-scan-table-' + nd.id;
      scanTable.style.cssText = 'font-size:10px;';
      if (nd.config._scanCheckData) {
        renderScanCheckTable(nd, scanTable, nd.config._scanCheckData);
      } else {
        scanTable.style.color = 'rgba(255,255,255,0.3)';
        scanTable.textContent = 'Lade…';
      }
      scanSec.appendChild(scanTable);

      async function doCertFetch() {
        checkBtn.disabled = true; checkBtn.textContent = '⟳ Lade…';
        try {
          const url = `/api/flow/batch_scan_check?test_id=${encodeURIComponent(testId)}&engine_type=${encodeURIComponent(engineType)}`;
          const resp = await fetch(url);
          const data = await resp.json();
          if (data.error) throw new Error(data.error);
          nd.config._scanCheckData = data;
          nd.config._current_test_id = testId;
          scanTable.style.color = '';
          renderScanCheckTable(nd, scanTable, data);
        } catch(e) {
          scanTable.style.color = 'var(--status-err,#e74c3c)';
          scanTable.textContent = 'Fehler: ' + e.message;
        } finally {
          checkBtn.disabled = false; checkBtn.textContent = '⟳ Scans prüfen';
        }
      }

      checkBtn.addEventListener('click', doCertFetch);
      setTimeout(doCertFetch, 0);
    }
    container.appendChild(scanSec);

  }).catch(() => {
    const err = document.createElement('div');
    err.className = 'cp-label'; err.style.color = 'var(--status-err,#e74c3c)';
    err.textContent = 'Fehler beim Laden des Recipes.';
    container.appendChild(err);
  });
}

// ── pdf_compose custom panel ─────────────────────────────────────────
function renderPdfComposePanel(nd, container) {
  if (!nd.config.section_labels) nd.config.section_labels = {};

  const sec = document.createElement('div');
  sec.style.cssText = 'margin-top:14px;';

  const hdr = document.createElement('div');
  hdr.className = 'cp-label';
  hdr.style.cssText = 'font-size:9px;letter-spacing:0.18em;text-transform:uppercase;margin-bottom:8px;';
  hdr.textContent = 'SEKTIONS-BEZEICHNUNGEN';
  sec.appendChild(hdr);

  const hint = document.createElement('div');
  hint.style.cssText = 'font-size:9px;color:rgba(255,255,255,0.35);margin-bottom:10px;font-style:italic;';
  hint.textContent = 'Port 0 = Meta (HB Read). Ports 1–8 = Plot- oder Display-Nodes.';
  sec.appendChild(hint);

  for (let i = 1; i <= 8; i++) {
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px;';

    const portLbl = document.createElement('span');
    portLbl.style.cssText = 'font-size:9px;color:rgba(255,255,255,0.35);width:40px;flex-shrink:0;';
    portLbl.textContent = `Sek. ${i}`;
    row.appendChild(portLbl);

    const inp = document.createElement('input');
    inp.className = 'cp-input';
    inp.type = 'text';
    inp.placeholder = `Bezeichnung Sektion ${i}`;
    inp.value = nd.config.section_labels[String(i)] || '';
    inp.addEventListener('input', () => {
      nd.config.section_labels[String(i)] = inp.value.trim();
    });
    row.appendChild(inp);
    sec.appendChild(row);
  }

  container.appendChild(sec);
}

// ── scan_query node custom panel ─────────────────────────────────────
function renderScanQueryPanel(nd, container) {
  // Find upstream tardis_query on port 0
  const tardisConn = connections.find(c => c.toNodeId === nd.id && c.toPortIdx === 0);
  const tardisNode = tardisConn ? nodes.find(n => n.id === tardisConn.fromNodeId) : null;
  const testId     = tardisNode ? (tardisNode.config.th_test_id || '') : (nd.config.testId || '');

  // Manual Test-ID input (always shown as fallback)
  if (!tardisNode) {
    const lbl = document.createElement('label');
    lbl.style.cssText = 'display:flex;flex-direction:column;gap:4px;font-size:10px;color:rgba(255,255,255,0.55);margin-bottom:8px;';
    lbl.textContent = 'TEST-ID';
    const inp = document.createElement('input');
    inp.className = 'cp-input'; inp.type = 'text'; inp.placeholder = 'z.B. 123456';
    inp.value = nd.config.testId || '';
    inp.addEventListener('input', () => { nd.config.testId = inp.value.trim(); updateStatusDot(nd.id); });
    lbl.appendChild(inp); container.appendChild(lbl);
  } else {
    const info = document.createElement('div');
    info.style.cssText = 'font-size:9px;color:rgba(255,255,255,0.35);margin-bottom:8px;';
    info.textContent = 'Test-ID: ' + (testId || '(TARDIS Query verbunden, Test-ID noch nicht gesetzt)');
    container.appendChild(info);
  }

  // Scan-Nr filter
  const lbl2 = document.createElement('label');
  lbl2.style.cssText = 'display:flex;flex-direction:column;gap:4px;font-size:10px;color:rgba(255,255,255,0.55);margin-bottom:10px;';
  lbl2.textContent = 'SCAN-NR (LEER = ALLE)';
  const inpNr = document.createElement('input');
  inpNr.className = 'cp-input'; inpNr.type = 'text'; inpNr.placeholder = 'z.B. 1';
  inpNr.value = nd.config.scanNr || '';
  inpNr.addEventListener('input', () => { nd.config.scanNr = inpNr.value.trim(); updateStatusDot(nd.id); });
  lbl2.appendChild(inpNr); container.appendChild(lbl2);

  // Scan-Code section
  const secLabel = document.createElement('div');
  secLabel.style.cssText = 'font-size:9px;letter-spacing:0.18em;text-transform:uppercase;color:rgba(255,255,255,0.55);margin-bottom:6px;';
  secLabel.textContent = 'SCAN-CODES';
  container.appendChild(secLabel);

  const codeList = document.createElement('div');
  codeList.id = 'scan-query-codes-' + nd.id;
  codeList.style.cssText = 'font-size:10px;margin-bottom:8px;';
  container.appendChild(codeList);

  function renderCodeList(codes) {
    codeList.innerHTML = '';
    if (!codes || codes.length === 0) {
      codeList.style.color = 'rgba(255,255,255,0.3)';
      codeList.textContent = 'Keine Codes gefunden.';
      return;
    }
    const selected = new Set(nd.config.scanCodes || []);
    codes.forEach(code => {
      const row = document.createElement('label');
      row.style.cssText = 'display:flex;align-items:center;gap:7px;padding:4px 6px;border-radius:3px;cursor:pointer;'
        + 'font-size:10px;color:rgba(255,255,255,0.75);transition:background 0.1s;';
      row.addEventListener('mouseenter', () => row.style.background = 'rgba(255,255,255,0.06)');
      row.addEventListener('mouseleave', () => row.style.background = '');
      const cb = document.createElement('input');
      cb.type = 'checkbox'; cb.checked = selected.has(code);
      cb.style.cssText = 'accent-color:#0ea5e9;width:12px;height:12px;cursor:pointer;';
      cb.addEventListener('change', () => {
        const cur = new Set(nd.config.scanCodes || []);
        cb.checked ? cur.add(code) : cur.delete(code);
        nd.config.scanCodes = [...cur];
        updateStatusDot(nd.id);
      });
      const span = document.createElement('span'); span.textContent = code;
      row.appendChild(cb); row.appendChild(span);
      codeList.appendChild(row);
    });
  }

  // Render cached codes if available
  if (nd.config._availableCodes) {
    renderCodeList(nd.config._availableCodes);
  } else {
    codeList.style.color = 'rgba(255,255,255,0.3)';
    codeList.textContent = testId ? 'Noch nicht geladen.' : 'TARDIS Query verbinden oder Test-ID eingeben.';
  }

  // "Codes laden" button
  if (testId) {
    const btn = document.createElement('button');
    btn.textContent = '⟳ Scan-Codes laden';
    btn.style.cssText = 'font-size:9px;padding:4px 10px;border-radius:3px;cursor:pointer;width:100%;'
      + 'background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.2);'
      + 'color:rgba(255,255,255,0.7);letter-spacing:0.05em;';
    btn.addEventListener('click', async () => {
      btn.textContent = '⟳ Lade…'; btn.disabled = true;
      try {
        const resp = await fetch(`/api/flow/scan_codes?test_id=${encodeURIComponent(testId)}`);
        const data = await resp.json();
        if (data.error) throw new Error(data.error);
        nd.config._availableCodes = data.codes;
        renderCodeList(data.codes);
      } catch(e) {
        codeList.style.color = 'var(--status-err,#e74c3c)';
        codeList.textContent = 'Fehler: ' + e.message;
      } finally {
        btn.textContent = '⟳ Scan-Codes laden'; btn.disabled = false;
      }
    });
    container.appendChild(btn);
  }
}

// ── check node custom panel ───────────────────────────────────────────
function renderCheckPanel(nd, container) {
  // Find recipe connection on port 1
  const recipeConn = connections.find(c => c.toNodeId === nd.id && c.toPortIdx === 1);
  const recipeNode = recipeConn ? nodes.find(n => n.id === recipeConn.fromNodeId) : null;

  if (!recipeNode) {
    // No recipe connected — show manual rules textarea
    const lbl = document.createElement('label');
    lbl.style.cssText = 'display:flex;flex-direction:column;gap:4px;font-size:10px;color:rgba(255,255,255,0.55);';
    lbl.textContent = 'GRENZWERT-REGELN (EINE PRO ZEILE)';
    const ta = document.createElement('textarea');
    ta.className = 'cp-textarea';
    ta.style.cssText = 'min-height:90px;resize:vertical;';
    ta.placeholder = 'N1 > 5000\nEGT < 800\n5000 < N1 < 8000\nT60* > 100\n* > 0\n# Kommentare mit #';
    ta.value = nd.config.rules || '';
    ta.addEventListener('input', () => { nd.config.rules = ta.value; updateStatusDot(nd.id); });
    lbl.appendChild(ta);
    container.appendChild(lbl);
    return;
  }

  // Recipe connected — find test_id from port 0 (TARDIS Query)
  const tardisConn = connections.find(c => c.toNodeId === nd.id && c.toPortIdx === 0);
  const tardisNode = tardisConn ? nodes.find(n => n.id === tardisConn.fromNodeId) : null;
  const testId     = tardisNode ? (tardisNode.config.th_test_id || '') : '';
  const engineType = recipeNode.config.engineType || '';

  // Header: Test-ID info
  const header = document.createElement('div');
  header.style.cssText = 'font-size:9px;color:rgba(255,255,255,0.4);margin-bottom:8px;';
  header.textContent = testId
    ? `Test-ID: ${testId} · Engine: ${engineType}`
    : `Engine: ${engineType} — kein TARDIS Query (Port 0) verbunden`;
  container.appendChild(header);

  // Scan table container (defined early so button click can reference it)
  const scanTable = document.createElement('div');
  scanTable.id = 'check-scan-table-' + nd.id;
  scanTable.style.cssText = 'font-size:10px;margin-top:8px;';

  // "Scans prüfen" button + auto-fetch on panel open
  if (testId && engineType) {
    const btn = document.createElement('button');
    btn.className = 'flow-modal-btn';
    btn.textContent = '⟳ Scans prüfen';
    btn.style.cssText = 'width:100%;margin-bottom:8px;font-size:10px;padding:6px 10px;';

    async function doFetch() {
      btn.textContent = '⟳ Prüfe…';
      btn.disabled = true;
      scanTable.style.color = 'rgba(255,255,255,0.3)';
      scanTable.textContent = 'Lade…';
      try {
        const url = `/api/flow/batch_scan_check?test_id=${encodeURIComponent(testId)}&engine_type=${encodeURIComponent(engineType)}`;
        const resp = await fetch(url);
        const data = await resp.json();
        if (data.error) throw new Error(data.error);
        nd.config._scanCheckData = data;
        nd.config._current_test_id = testId;
        scanTable.style.color = '';
        renderScanCheckTable(nd, scanTable, data);
      } catch(e) {
        scanTable.style.color = 'var(--status-err,#e74c3c)';
        scanTable.textContent = 'Fehler: ' + e.message;
      } finally {
        btn.textContent = '⟳ Scans prüfen';
        btn.disabled = false;
      }
    }

    btn.addEventListener('click', doFetch);
    container.appendChild(btn);

    // Auto-fetch wenn Panel geöffnet wird (kein manueller Klick nötig)
    setTimeout(doFetch, 0);
  } else if (!testId) {
    const hint = document.createElement('div');
    hint.style.cssText = 'font-size:9px;color:rgba(245,158,11,0.7);margin-bottom:6px;';
    hint.textContent = '⚠︎ TARDIS Query (Port 0) mit konfigurierter Test-ID verbinden';
    container.appendChild(hint);
  }

  // Scan table — below button
  if (nd.config._scanCheckData) {
    renderScanCheckTable(nd, scanTable, nd.config._scanCheckData);
  } else {
    scanTable.style.color = 'rgba(255,255,255,0.3)';
    scanTable.textContent = 'Noch nicht geprüft.';
  }
  container.appendChild(scanTable);
}

function renderScanCheckTable(nd, container, data) {
  container.innerHTML = '';
  if (!nd.config.selected_scans) nd.config.selected_scans = {};

  // ── Diagnostik-Banner ─────────────────────────────────────────────
  const meta = data._meta;
  if (meta) {
    const banner = document.createElement('div');
    const totalRows = meta.step1_total_rows || 0;
    const cgCounts  = Object.values(meta.step1_cg_rows || {});
    const maxCg     = cgCounts.length ? Math.max(...cgCounts) : 0;
    const warn      = cgCounts.some(n => n >= 100);  // Mögliches TARDIS-Limit
    banner.style.cssText = [
      'font-size:9px;margin-bottom:6px;padding:3px 6px;border-radius:3px;',
      warn
        ? 'background:rgba(245,158,11,0.15);color:rgba(245,158,11,0.85);border:1px solid rgba(245,158,11,0.3);'
        : 'background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.35);border:1px solid rgba(255,255,255,0.08);'
    ].join('');
    banner.textContent = warn
      ? `⚠︎ TARDIS-Limit möglich: ${totalRows} Zeilen (max CG: ${maxCg}) — möglicherweise nicht alle Scans sichtbar`
      : `✓ TARDIS: ${totalRows} Zeilen geladen (${cgCounts.length} CG${cgCounts.length !== 1 ? 's' : ''})`;
    banner.title = JSON.stringify(meta.step1_cg_rows, null, 2);
    container.appendChild(banner);
  }

  const codes = Object.keys(data).filter(k => k !== '_meta');
  if (!codes.length) {
    container.style.color = 'rgba(255,255,255,0.35)';
    container.textContent = 'Keine Scan-Daten gefunden.';
    return;
  }
  codes.forEach(code => {
    const codeData = data[code];
    const scanNrs = Object.keys(codeData).sort((a, b) => parseFloat(a) - parseFloat(b));

    const row = document.createElement('div');
    row.style.cssText = 'display:flex;align-items:center;gap:5px;margin-bottom:6px;flex-wrap:wrap;';

    const codeLbl = document.createElement('span');
    codeLbl.style.cssText = 'font-size:9px;color:rgba(255,255,255,0.45);width:72px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
    codeLbl.textContent = code;
    codeLbl.title = code;
    row.appendChild(codeLbl);

    if (!scanNrs.length) {
      delete nd.config.selected_scans[code];
      const empty = document.createElement('span');
      empty.style.cssText = 'font-size:9px;color:rgba(255,255,255,0.25);font-style:italic;';
      empty.textContent = '(keine Daten)';
      row.appendChild(empty);
      container.appendChild(row);
      return;
    }

    // Auto-select best scan (highest nr that is __ok); clear stale selections from old test/saved flow
    const selectedNr = nd.config.selected_scans[code];
    if (selectedNr && !scanNrs.includes(String(selectedNr))) {
      delete nd.config.selected_scans[code];
    }
    if (!nd.config.selected_scans[code]) {
      const bestNr = scanNrs.slice().reverse().find(nr => codeData[nr]?.__ok) || scanNrs[scanNrs.length - 1];
      nd.config.selected_scans[code] = bestNr;
    }

    scanNrs.forEach(nr => {
      const scanRow = codeData[nr] || {};
      const isOk = scanRow.__ok !== false;
      const violations = scanRow.__violations || [];
      const isActive = String(nd.config.selected_scans[code]) === String(nr);

      const btn = document.createElement('button');
      btn.style.cssText = [
        'font-size:9px;padding:2px 7px;border-radius:3px;cursor:pointer;border:1px solid;',
        'background:' + (isOk ? 'rgba(34,197,94,0.15)' : 'rgba(192,57,43,0.15)') + ';',
        'border-color:' + (isActive ? (isOk ? '#22c55e' : '#ef4444') : (isOk ? 'rgba(34,197,94,0.5)' : 'rgba(192,57,43,0.4)')) + ';',
        'color:' + (isActive ? (isOk ? '#22c55e' : '#ef4444') : (isOk ? 'rgba(34,197,94,0.8)' : 'rgba(255,255,255,0.45)')) + ';',
        'font-weight:' + (isActive ? '700' : '400') + ';',
      ].join('');
      btn.textContent = nr + (isOk ? ' ✓' : ' ✗');
      btn.title = violations.length ? violations.join(', ') : 'OK';

      btn.addEventListener('click', () => {
        nd.config.selected_scans[code] = nr;
        nd.config._last_test_id = nd.config._current_test_id || '';
        renderScanCheckTable(nd, container, data);
      });
      row.appendChild(btn);
    });
    container.appendChild(row);
  });
}

const _NODE_SETTINGS_MAP = {
  'cipa_cert':      'cipa-config',
  'recipe':         'recipes',
  'cert_build':     'recipes',
  'pdf_source':     'engine-mappings',
  'template_steps': 'step-templates',
  'custom_steps':   'step-templates',
};

function openConfigPanel(nodeId) {
  // deselect previous
  if (selectedNode !== null) {
    const prev = nodes.find(n => n.id === selectedNode);
    if (prev) prev.el.classList.remove('selected');
  }
  selectedNode = nodeId;
  const nd = nodes.find(n => n.id === nodeId);
  if (nd) nd.el.classList.add('selected');
  configPanel.dataset.nodeId = nodeId;
  renderConfigPanel(nodeId);
  configPanel.classList.add('open');

  // If settings panel is open, jump to the relevant section (visual switch only, no re-render)
  const settingsOverlay = document.getElementById('settings-overlay');
  if (nd && settingsOverlay && !settingsOverlay.classList.contains('hidden')) {
    const target = _NODE_SETTINGS_MAP[nd.typeId];
    if (target) _switchSettingsSectionSilent(target);
  }
}

function closeConfigPanel() {
  if (selectedNode !== null) {
    const nd = nodes.find(n => n.id === selectedNode);
    if (nd) nd.el.classList.remove('selected');
  }
  selectedNode = null;
  configPanel.classList.remove('open');
}

cpClose.addEventListener('click', closeConfigPanel);

// ── create node ───────────────────────────────────────────────────

function createNode(typeId, x, y) {
  const type = NODE_TYPES[typeId];
  const id   = nodeCount++;

  const el = document.createElement('div');
  el.className  = 'node';
  el.dataset.id = id;
  el.style.left = x + 'px';
  el.style.top  = y + 'px';

  // delete button
  const del = document.createElement('span');
  del.className   = 'node-delete';
  del.textContent = '×';
  del.addEventListener('click', e => { e.stopPropagation(); deleteNode(id); });
  el.appendChild(del);

  // header
  const hdr = document.createElement('div');
  hdr.className  = 'node-header';
  hdr.style.background = hexAlpha(type.color, 0.14);
  const statusDot = document.createElement('span');
  statusDot.className = 'node-status';
  hdr.innerHTML  = `<span class="node-icon">${type.icon}</span><span class="node-title">${type.label}</span>`;
  hdr.appendChild(statusDot);
  el.appendChild(hdr);

  // click on node → open config
  el.addEventListener('click', e => {
    if (e.target.closest('.port') || e.target.closest('.node-delete')) return;
    e.stopPropagation();
    openConfigPanel(id);
  });

  // body — rows of ports
  const body = document.createElement('div');
  body.className = 'node-body';
  const maxPorts = Math.max(type.inputs.length, type.outputs.length);

  for (let i = 0; i < maxPorts; i++) {
    const row = document.createElement('div');
    row.className = 'node-row';

    // input side
    if (i < type.inputs.length) {
      const wrap = document.createElement('div');
      wrap.className = 'port-wrap in';
      const dot = document.createElement('div');
      dot.className = 'port port-in';
      dot.style.borderColor = type.color;
      dot.style.color       = type.color;
      dot.dataset.nodeId    = id;
      dot.dataset.portIdx   = i;
      dot.dataset.isOutput  = '0';
      const lbl = document.createElement('span');
      lbl.className   = 'port-label';
      lbl.textContent = type.inputs[i];
      wrap.appendChild(dot);
      wrap.appendChild(lbl);
      row.appendChild(wrap);
    } else {
      row.appendChild(document.createElement('div'));
    }

    // output side
    if (i < type.outputs.length) {
      const wrap = document.createElement('div');
      wrap.className = 'port-wrap out';
      const dot = document.createElement('div');
      dot.className = 'port port-out';
      dot.style.borderColor = type.color;
      dot.style.color       = type.color;
      dot.dataset.nodeId    = id;
      dot.dataset.portIdx   = i;
      dot.dataset.isOutput  = '1';
      const lbl = document.createElement('span');
      lbl.className   = 'port-label';
      lbl.textContent = type.outputs[i];
      wrap.appendChild(lbl);
      wrap.appendChild(dot);
      row.appendChild(wrap);
    } else {
      row.appendChild(document.createElement('div'));
    }

    body.appendChild(row);
  }
  el.appendChild(body);

  // note-node: sticky-note appearance + text preview
  if (typeId === 'note') {
    el.classList.add('note-node');
    const preview = document.createElement('div');
    preview.className = 'note-preview';
    preview.textContent = '…';
    el.appendChild(preview);
  }

  // group-node: colored box behind all other nodes
  if (typeId === 'group') {
    const GROUP_COLORS = {
      indigo: { bg: 'rgba(99,102,241,0.07)',  border: 'rgba(99,102,241,0.35)',  text: 'rgba(165,167,249,0.9)' },
      teal:   { bg: 'rgba(20,184,166,0.07)',   border: 'rgba(20,184,166,0.35)',  text: 'rgba(94,234,212,0.9)' },
      amber:  { bg: 'rgba(245,158,11,0.07)',   border: 'rgba(245,158,11,0.35)',  text: 'rgba(252,211,77,0.9)' },
      rose:   { bg: 'rgba(244,63,94,0.07)',    border: 'rgba(244,63,94,0.35)',   text: 'rgba(251,113,133,0.9)' },
      slate:  { bg: 'rgba(100,116,139,0.07)',  border: 'rgba(100,116,139,0.35)', text: 'rgba(148,163,184,0.9)' },
    };
    el.classList.add('group-node');
    el._groupColors = GROUP_COLORS;

    function applyGroupStyle() {
      const col = GROUP_COLORS[el._groupColor || 'indigo'];
      el.style.background   = col.bg;
      el.style.borderColor  = col.border;
      el.style.width        = (el._groupW || 280) + 'px';
      el.style.height       = (el._groupH || 200) + 'px';
      if (el._labelEl) { el._labelEl.textContent = el._groupLabel || ''; el._labelEl.style.color = col.text; }
    }
    el.applyGroupStyle = applyGroupStyle;

    // hide default header content, keep header as drag handle only
    hdr.innerHTML = '';
    hdr.style.background = 'transparent';
    hdr.style.height = '28px';
    hdr.style.cursor = 'move';

    // floating label
    const labelEl = document.createElement('div');
    labelEl.className = 'group-label';
    el._labelEl = labelEl;
    el.appendChild(labelEl);

    // resize handle
    const resizeHandle = document.createElement('div');
    resizeHandle.className = 'group-resize';
    resizeHandle.textContent = '⤡';
    resizeHandle.addEventListener('mousedown', e => {
      e.preventDefault(); e.stopPropagation();
      resizingGroup = {
        el, startX: e.clientX, startY: e.clientY,
        startW: el._groupW || 280, startH: el._groupH || 200,
      };
    });
    el.appendChild(resizeHandle);

    el._groupW = 280; el._groupH = 200;
    el._groupLabel = ''; el._groupColor = 'indigo';
    el._collapsed = false;
    applyGroupStyle();

    // collapse button
    const collapseBtn = document.createElement('span');
    collapseBtn.className   = 'group-collapse-btn';
    collapseBtn.textContent = '▾';
    collapseBtn.title       = 'Einklappen';
    collapseBtn.addEventListener('click', e => { e.stopPropagation(); toggleGroup(id); });
    hdr.appendChild(collapseBtn);
    el._collapseBtn = collapseBtn;

    // drag group — with contained nodes
    hdr.addEventListener('mousedown', e => {
      e.preventDefault(); e.stopPropagation();
      const cr = canvasRect();
      const contained = nodes.filter(n => n.config.groupId === id && n.typeId !== 'group');
      draggingNode = {
        node: { id, el },
        ox: e.clientX - cr.left - parseFloat(el.style.left),
        oy: e.clientY - cr.top  - parseFloat(el.style.top),
        extras: contained.map(nd => ({
          el: nd.el,
          ox: e.clientX - cr.left - parseFloat(nd.el.style.left),
          oy: e.clientY - cr.top  - parseFloat(nd.el.style.top),
        })),
        isGroup: true,
      };
      el.style.zIndex = 4;
    });

    // insert before first regular node so it stays behind
    canvasArea.insertBefore(el, canvasArea.firstChild);
    nodes.push({ id, typeId, el, config: {} });
    updateEmptyHint();
    return;
  }

  // drag node by header
  hdr.addEventListener('mousedown', e => {
    e.preventDefault(); e.stopPropagation();
    const cr = canvasRect();
    draggingNode = {
      node: { id, el },
      ox: e.clientX - cr.left - parseFloat(el.style.left),
      oy: e.clientY - cr.top  - parseFloat(el.style.top),
    };
    el.style.zIndex = 100;
  });

  canvasArea.appendChild(el);
  const newNodeCfg = Object.assign({}, nodeDefaults[typeId] || {});
  nodes.push({ id, typeId, el, config: newNodeCfg });
  updateEmptyHint();
}

// ── delete node ───────────────────────────────────────────────────

function deleteNode(id) {
  if (selectedNode === id) closeConfigPanel();
  connections.filter(c => c.fromNodeId === id || c.toNodeId === id)
             .forEach(c => c.el.remove());
  connections = connections.filter(c => c.fromNodeId !== id && c.toNodeId !== id);
  const nd = nodes.find(n => n.id === id);
  if (nd) nd.el.remove();
  nodes = nodes.filter(n => n.id !== id);
  updateEmptyHint();
}

// ── connections ───────────────────────────────────────────────────

function addConnection(fromNodeId, fromPortIdx, toNodeId, toPortIdx) {
  const dup = connections.find(c =>
    c.fromNodeId === fromNodeId && c.fromPortIdx === fromPortIdx &&
    c.toNodeId === toNodeId     && c.toPortIdx === toPortIdx);
  if (dup) return;

  const fromNode = nodes.find(n => n.id === fromNodeId);
  const color    = NODE_TYPES[fromNode.typeId].color;

  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  path.classList.add('conn-path');
  path.style.stroke  = color;
  path.style.opacity = '0.72';
  path.style.pointerEvents = 'stroke';
  const connId = connCount++;
  path.dataset.connId = connId;
  path.addEventListener('dblclick', () => deleteConnection(connId));
  svgLayer.insertBefore(path, tempPath);

  const conn = { id: connId, fromNodeId, fromPortIdx, toNodeId, toPortIdx, el: path };
  connections.push(conn);
  markPort(fromNodeId, fromPortIdx, true,  true);
  markPort(toNodeId,   toPortIdx,   false, true);
  updateConnection(conn);
}

function deleteConnection(id) {
  const conn = connections.find(c => c.id === id);
  if (!conn) return;
  conn.el.remove();
  connections = connections.filter(c => c.id !== id);
  markPort(conn.fromNodeId, conn.fromPortIdx, true,
    connections.some(c => c.fromNodeId === conn.fromNodeId && c.fromPortIdx === conn.fromPortIdx));
  markPort(conn.toNodeId, conn.toPortIdx, false,
    connections.some(c => c.toNodeId === conn.toNodeId && c.toPortIdx === conn.toPortIdx));
}

function markPort(nodeId, portIdx, isOutput, connected) {
  const nd = nodes.find(n => n.id === nodeId);
  if (!nd) return;
  const ports = nd.el.querySelectorAll(isOutput ? '.port-out' : '.port-in');
  if (ports[portIdx]) ports[portIdx].classList.toggle('connected', connected);
}

// ── mouse events ──────────────────────────────────────────────────

canvasArea.addEventListener('mousedown', e => {
  const port = e.target.closest('.port');
  if (!port) return;
  e.preventDefault(); e.stopPropagation();
  if (port.dataset.isOutput !== '1') return;  // only start from output
  const nodeId  = parseInt(port.dataset.nodeId);
  const portIdx = parseInt(port.dataset.portIdx);
  const p = getPortCenter(nodeId, portIdx, true);
  connectState = { fromNodeId: nodeId, fromPortIdx: portIdx, x1: p.x, y1: p.y };
  tempPath.setAttribute('d', bezier(p.x, p.y, p.x, p.y));
});

canvasArea.addEventListener('mousemove', e => {
  if (resizingGroup) {
    const dx = e.clientX - resizingGroup.startX;
    const dy = e.clientY - resizingGroup.startY;
    resizingGroup.el._groupW = Math.max(160, resizingGroup.startW + dx);
    resizingGroup.el._groupH = Math.max(120, resizingGroup.startH + dy);
    resizingGroup.el.applyGroupStyle();
    return;
  }
  if (draggingNode) {
    const cr = canvasRect();
    draggingNode.node.el.style.left = (e.clientX - cr.left - draggingNode.ox) + 'px';
    draggingNode.node.el.style.top  = (e.clientY - cr.top  - draggingNode.oy) + 'px';
    if (draggingNode.extras) {
      for (const ex of draggingNode.extras) {
        ex.el.style.left = (e.clientX - cr.left - ex.ox) + 'px';
        ex.el.style.top  = (e.clientY - cr.top  - ex.oy) + 'px';
      }
    }
    updateAllConnections();
    return;
  }
  if (connectState) {
    const cr = canvasRect();
    tempPath.setAttribute('d', bezier(connectState.x1, connectState.y1,
      e.clientX - cr.left, e.clientY - cr.top));
  }
});

canvasArea.addEventListener('mouseup', e => {
  if (resizingGroup) {
    // sync config so it's saved with the flow
    const nd = nodes.find(n => n.el === resizingGroup.el);
    if (nd) { nd.config.width = resizingGroup.el._groupW; nd.config.height = resizingGroup.el._groupH; }
    resizingGroup = null;
    return;
  }
  if (draggingNode) {
    const droppedEl = draggingNode.node.el;
    const isGroup   = draggingNode.isGroup;
    droppedEl.style.zIndex = isGroup ? 2 : 10;
    draggingNode = null;
    // assign dropped regular node to a group if it landed inside one
    if (!isGroup) {
      const droppedNd = nodes.find(n => n.el === droppedEl);
      if (droppedNd) {
        const nx = parseFloat(droppedEl.style.left);
        const ny = parseFloat(droppedEl.style.top);
        let assigned = null;
        for (const gnd of nodes.filter(n => n.typeId === 'group' && !n.el._collapsed)) {
          const gx = parseFloat(gnd.el.style.left), gy = parseFloat(gnd.el.style.top);
          if (nx >= gx && nx <= gx + gnd.el._groupW && ny >= gy && ny <= gy + gnd.el._groupH) {
            assigned = gnd.id; break;
          }
        }
        droppedNd.config.groupId = assigned;
        updateNodeGroupBadge(droppedNd);
      }
    }
    return;
  }
  if (connectState) {
    const port = e.target.closest('.port-in');
    if (port) {
      const toNodeId  = parseInt(port.dataset.nodeId);
      const toPortIdx = parseInt(port.dataset.portIdx);
      if (toNodeId !== connectState.fromNodeId)
        addConnection(connectState.fromNodeId, connectState.fromPortIdx, toNodeId, toPortIdx);
    }
    tempPath.setAttribute('d', '');
    connectState = null;
  }
});

document.addEventListener('mouseup', () => {
  if (connectState)   { tempPath.setAttribute('d', ''); connectState = null; }
  if (draggingNode)   { draggingNode.node.el.style.zIndex = draggingNode.isGroup ? 2 : 10; draggingNode = null; }
  if (resizingGroup)  { resizingGroup = null; }
});

// ── palette drag & drop ───────────────────────────────────────────

const NODE_SETTINGS_MAP = {
  tardis_query: 'connections', scan_query: 'connections',
  cert_build:   'connections', check:      'connections',
  pdf_compose:  'connections',
  select:       'connections', filter:     'connections', transpose: 'connections',
  recipe:       'recipes',
  hb_read:      'connections', hb_write:     'connections', hb_patch: 'connections',
  pdf_source:   'engine-mappings', template_steps: 'connections', cluster_check: 'connections',
  custom_steps: 'step-templates',
};

document.querySelectorAll('.palette-item').forEach(item => {
  item.addEventListener('dragstart', e => {
    dragType = item.dataset.type;
    e.dataTransfer.effectAllowed = 'copy';
  });
  item.addEventListener('dragend', () => { dragType = null; });
  item.addEventListener('click', () => {
    const ov = document.getElementById('settings-overlay');
    if (ov.classList.contains('hidden')) return;
    const section = NODE_SETTINGS_MAP[item.dataset.type];
    if (section) switchSettingsSection(section);
  });
});

canvasArea.addEventListener('click', e => {
  if (!e.target.closest('.node')) closeConfigPanel();
});

canvasArea.addEventListener('dragover', e => {
  e.preventDefault();
  e.dataTransfer.dropEffect = 'copy';
  canvasArea.classList.add('drag-over');
});
canvasArea.addEventListener('dragleave', () => canvasArea.classList.remove('drag-over'));
canvasArea.addEventListener('drop', e => {
  e.preventDefault();
  canvasArea.classList.remove('drag-over');
  if (!dragType) return;
  const cr = canvasRect();
  const nx = e.clientX - cr.left - 74;
  const ny = e.clientY - cr.top  - 20;
  createNode(dragType, nx, ny);
  dragType = null;
  // assign to group if dropped inside one
  const newNd = nodes[nodes.length - 1];
  if (newNd && newNd.typeId !== 'group') {
    let assigned = null;
    for (const gnd of nodes.filter(n => n.typeId === 'group' && !n.el._collapsed)) {
      const gx = parseFloat(gnd.el.style.left), gy = parseFloat(gnd.el.style.top);
      if (nx >= gx && nx <= gx + gnd.el._groupW && ny >= gy && ny <= gy + gnd.el._groupH) {
        assigned = gnd.id; break;
      }
    }
    if (assigned !== null) {
      newNd.config.groupId = assigned;
      updateNodeGroupBadge(newNd);
    }
  }
});

// ── save / load / clear ───────────────────────────────────────────

function flowSnapshot() {
  return {
    nodes: nodes.map(nd => ({
      id: nd.id, typeId: nd.typeId, config: nd.config,
      x: parseFloat(nd.el.style.left), y: parseFloat(nd.el.style.top)
    })),
    connections: connections.map(c => ({
      fromNodeId: c.fromNodeId, fromPortIdx: c.fromPortIdx,
      toNodeId:   c.toNodeId,   toPortIdx:   c.toPortIdx
    }))
  };
}

function clearCanvas() {
  [...nodes].forEach(nd => deleteNode(nd.id));
  nodeCount = 0; connCount = 0;
  closeConfigPanel();
}

function restoreFlow(data) {
  clearCanvas();
  const idMap = {};
  for (const nd of data.nodes) {
    createNode(nd.typeId, nd.x ?? 100, nd.y ?? 100);
    const newNd = nodes[nodes.length - 1];
    newNd.config = nd.config || {};
    idMap[nd.id] = newNd.id;
    updateStatusDot(newNd.id);
    if (nd.typeId === 'note') {
      const prev = newNd.el.querySelector('.note-preview');
      if (prev) prev.textContent = newNd.config.text || '…';
    }
    if (nd.typeId === 'group') {
      newNd.el._groupLabel = newNd.config.label  || '';
      newNd.el._groupColor = newNd.config.color  || 'indigo';
      newNd.el._groupW     = newNd.config.width  || 280;
      newNd.el._groupH     = newNd.config.height || 200;
      newNd.el.applyGroupStyle();
    }
  }
  // remap groupId (old node IDs → new node IDs)
  for (const nd of nodes) {
    if (nd.config.groupId != null) {
      nd.config.groupId = idMap[nd.config.groupId] ?? null;
      updateNodeGroupBadge(nd);
    }
  }
  // create connections first so toggleGroup can classify them
  for (const c of data.connections) {
    addConnection(idMap[c.fromNodeId], c.fromPortIdx,
                  idMap[c.toNodeId],   c.toPortIdx);
  }
  // re-collapse groups (connections now exist → groupProxy gets set correctly)
  for (const nd of nodes.filter(n => n.typeId === 'group' && n.config.collapsed)) {
    nd.el._preCollapseH = nd.config.preCollapseH || 200;
    toggleGroup(nd.id);
  }
  // defer final redraw until browser has laid out all elements
  requestAnimationFrame(() => updateAllConnections());
  // notify badge system if available (set after this function is defined)
  if (typeof _onFlowRestored === 'function') _onFlowRestored(data);
}

// ── Flow categories ───────────────────────────────────────────────
// Loaded from backend: [{uuid, name}, ...]
let _allCategories = [];

(async () => {
  try {
    const d = await fetch('/api/flow/categories').then(r => r.json());
    _allCategories = d.categories || [];
  } catch(e) {}
})();

// ── Auto-load flow via URL param (?open=<file>&esn=<esn>&wo=<wo>) ──
(async () => {
  const params   = new URLSearchParams(window.location.search);
  const openFile = params.get('open');
  if (!openFile) return;
  try {
    const data = await fetch('/api/flow/load/' + encodeURIComponent(openFile)).then(r => r.json());
    if (!data?.nodes) return;

    const esn = params.get('esn') || '';
    const wo  = params.get('wo')  || '';
    if (esn || wo) {
      // ── Inject HB Read: resolve orderId ──────────────────────────
      for (const nd of data.nodes) {
        if (nd.typeId !== 'hb_read') continue;
        const hbRes = await fetch(`/api/hb/find_order?esn=${encodeURIComponent(esn)}&wo=${encodeURIComponent(wo)}`)
          .then(r => r.json()).catch(() => null);
        if (hbRes && !hbRes.error) {
          nd.config.orderId = hbRes.order_id;
          nd.config.hb_esn  = hbRes.esn;
          nd.config.hb_wo   = hbRes.wo;
        }

        // ── Inject TARDIS Query: resolve channelgroupId ───────────
        const findConn = (fromId) => data.connections?.find(c => c.fromNodeId === fromId &&
          data.nodes.find(n => n.id === c.toNodeId)?.typeId === 'tardis_query');
        const directConn = findConn(nd.id);
        const tardisNd = directConn ? data.nodes.find(n => n.id === directConn.toNodeId) : null;
        if (!tardisNd) continue;

        const match = await fetch(`/api/tardis/find_test?esn=${encodeURIComponent(esn)}&wo=${encodeURIComponent(wo)}`)
          .then(r => r.json()).catch(() => null);
        if (!match || match.error) continue;

        const steps = await fetch(`/api/tardis/teststeps?test_id=${encodeURIComponent(match.test_id)}`).then(r => r.json()).catch(() => []);
        const step  = steps.find(s => s.name?.includes('LUD')) || steps[0];
        if (!step) continue;

        const meas = await fetch(`/api/tardis/measurements?teststep_id=${encodeURIComponent(step.id)}`).then(r => r.json()).catch(() => []);
        const m    = meas.find(x => x.name?.toLowerCase().includes('absolute')) || meas[0];
        if (!m) continue;

        const cgs = await fetch(`/api/tardis/channelgroups?measurement_id=${encodeURIComponent(m.id)}`).then(r => r.json()).catch(() => []);
        const cg  = cgs[0];
        if (!cg) continue;

        tardisNd.config.th_project        = match.project;
        tardisNd.config.th_pool           = match.pool;
        tardisNd.config.th_test_id        = match.test_id;
        tardisNd.config.th_teststep_id    = step.id;
        tardisNd.config.th_measurement_id = m.id;
        tardisNd.config.channelgroupId    = cg.id;
      }
    }

    restoreFlow(data);
  } catch(e) {}
})();

function _tagColor(tag) {
  let h = 0;
  for (let i = 0; i < tag.length; i++) h = (h * 31 + tag.charCodeAt(i)) & 0xffff;
  return `hsl(${h % 360},50%,58%)`;
}

// ── Flow badge ────────────────────────────────────────────────────
let _loadedFlow = null;   // { name, file, category, tags, description } or null

function _updateFlowBadge() {
  const badge = document.getElementById('flow-badge');
  if (!badge) return;
  if (_loadedFlow) {
    badge.textContent = _loadedFlow.name;
    badge.className   = 'loaded';
  } else {
    badge.textContent = 'Neuer Flow';
    badge.className   = '';
  }
}

// Hook into restoreFlow (defined earlier, calls _onFlowRestored if present)
function _onFlowRestored(data) {
  if (data && data.name) {
    _loadedFlow = {
      name:        data.name,
      file:        (data.name||'').replace(/[^a-zA-Z0-9_\-]/g, '_'),
      category:    data.category || null,
      tags:        data.tags || [],
      description: data.description || ''
    };
  } else {
    _loadedFlow = null;
  }
  _updateFlowBadge();
}

// Also reset badge when canvas is manually cleared
const _origClearCanvas = clearCanvas;
clearCanvas = function() {
  _origClearCanvas();
  _loadedFlow = null;
  _updateFlowBadge();
};

// ── Save modal ────────────────────────────────────────────────────
const modalSave = document.getElementById('modal-save');
const saveInput = document.getElementById('save-name-input');
let _savedFlows = [], _saveCategory = null, _saveTags = [];

function _catName(uuid) {
  const c = _allCategories.find(x => x.uuid === uuid);
  return c ? c.name : uuid;
}

function _renderCatPills() {
  const wrap = document.getElementById('save-cat-pills');
  wrap.innerHTML = '';
  // "Keine" pill
  const noneBtn = document.createElement('button');
  noneBtn.className = 'cat-pill' + (_saveCategory === null ? ' selected' : '');
  noneBtn.textContent = 'Keine';
  noneBtn.addEventListener('click', () => { _saveCategory = null; _renderCatPills(); });
  wrap.appendChild(noneBtn);

  _allCategories.forEach(cat => {
    const sel = _saveCategory === cat.uuid;
    const btn = document.createElement('button');
    btn.className = 'cat-pill' + (sel ? ' selected' : '');
    btn.style.borderColor = _tagColor(cat.name);
    if (sel) { btn.style.background = _tagColor(cat.name) + '33'; btn.style.color = _tagColor(cat.name); }
    btn.textContent = cat.name;
    btn.addEventListener('click', () => { _saveCategory = cat.uuid; _renderCatPills(); });
    wrap.appendChild(btn);
  });
}

function _renderTagChips() {
  const wrap = document.getElementById('save-tag-chips');
  wrap.innerHTML = '';
  _saveTags.forEach((tag, i) => {
    const chip = document.createElement('span');
    chip.className = 'tag-chip';
    chip.innerHTML = `${tag}<button class="tag-chip-remove" data-i="${i}">×</button>`;
    chip.querySelector('.tag-chip-remove').addEventListener('click', () => {
      _saveTags.splice(i, 1); _renderTagChips();
    });
    wrap.appendChild(chip);
  });
  // "+ Tag" add button
  const addBtn = document.createElement('button');
  addBtn.className = 'tag-chip-add';
  addBtn.textContent = '+ Tag';
  addBtn.addEventListener('click', () => {
    addBtn.replaceWith(inp);
    inp.focus();
  });
  const inp = document.createElement('input');
  inp.className = 'tag-chip-input';
  inp.placeholder = 'Tag eingeben…';
  inp.addEventListener('keydown', e => {
    if (e.key === 'Enter' || e.key === ',') {
      e.preventDefault();
      const val = inp.value.trim().replace(/,+$/, '');
      if (val && !_saveTags.includes(val)) _saveTags.push(val);
      _renderTagChips();
    }
    if (e.key === 'Escape') _renderTagChips();
  });
  inp.addEventListener('blur', () => {
    const val = inp.value.trim();
    if (val && !_saveTags.includes(val)) _saveTags.push(val);
    _renderTagChips();
  });
  wrap.appendChild(addBtn);
}

function _refreshSaveOverwrite() {
  const exists = _savedFlows.some(f => f.name === saveInput.value.trim());
  document.getElementById('save-overwrite-warn').classList.toggle('hidden', !exists);
}

function _renderSaveGrid() {
  const wrap = document.getElementById('save-grid-wrap');
  wrap.innerHTML = '';
  if (_savedFlows.length === 0) {
    wrap.innerHTML = '<div class="flow-list-empty">Noch keine Flows gespeichert</div>';
    return;
  }

  const makeCard = (flow) => {
    const card = document.createElement('div');
    card.className = 'flow-card';
    card.style.cursor = 'pointer';
    const tagsHtml = (flow.tags||[]).map(t =>
      `<span class="flow-tag">${t}</span>`).join('');
    const date = flow.saved_at ? flow.saved_at.split('T')[0] : '–';
    const isLoaded = _loadedFlow && _loadedFlow.file === flow.file;
    card.innerHTML = `
      <div class="flow-card-header">
        <div class="flow-card-name">${flow.name}${isLoaded ? ' <span style="font-size:9px;color:#4ade80;font-weight:400;letter-spacing:0.05em">● geöffnet</span>' : ''}</div>
      </div>
      ${flow.description ? `<div class="flow-card-desc">${flow.description}</div>` : ''}
      ${tagsHtml ? `<div class="flow-card-tags">${tagsHtml}</div>` : ''}
      <div class="flow-card-footer">
        <div class="flow-card-meta">${flow.saved_by||'–'} · ${date}</div>
      </div>`;
    card.addEventListener('click', () => {
      saveInput.value = flow.name;
      _saveCategory = flow.category || null;
      _saveTags = [...(flow.tags||[])];
      _renderCatPills(); _renderTagChips();
      _refreshSaveOverwrite();
      saveInput.focus();
    });
    return card;
  };

  // Build groups in category order, uncategorised last
  const groups = [];
  _allCategories.forEach(cat => {
    const members = _savedFlows.filter(f => f.category === cat.uuid);
    if (members.length) groups.push({ cat, flows: members });
  });
  const uncategorised = _savedFlows.filter(f => !f.category);
  if (uncategorised.length) groups.push({ cat: null, flows: uncategorised });

  groups.forEach(({ cat, flows }) => {
    const section = document.createElement('div');
    section.className = 'flow-group';
    const hdr = document.createElement('div');
    hdr.className = cat ? 'flow-group-header' : 'flow-group-header uncategorized';
    if (cat) hdr.style.borderBottomColor = _tagColor(cat.name) + '55';
    hdr.textContent = cat ? cat.name : 'Ohne Gruppe';
    section.appendChild(hdr);
    const grid = document.createElement('div');
    grid.className = 'flow-card-grid';
    flows.forEach(flow => grid.appendChild(makeCard(flow)));
    section.appendChild(grid);
    wrap.appendChild(section);
  });
}

document.getElementById('btn-save').addEventListener('click', async () => {
  saveInput.value = '';
  document.getElementById('save-desc-input').value = '';
  document.getElementById('save-overwrite-warn').classList.add('hidden');
  // pre-fill from currently loaded flow
  if (_loadedFlow) {
    saveInput.value  = _loadedFlow.name;
    _saveCategory    = _loadedFlow.category || null;
    _saveTags        = [...(_loadedFlow.tags||[])];
    document.getElementById('save-desc-input').value = _loadedFlow.description || '';
  } else {
    _saveCategory = null; _saveTags = [];
  }
  _renderCatPills(); _renderTagChips();
  _savedFlows = await fetch('/api/flow/list').then(r => r.json()).catch(() => []);
  _renderSaveGrid();
  _refreshSaveOverwrite();
  modalSave.classList.remove('hidden');
  setTimeout(() => saveInput.focus(), 50);
});

saveInput.addEventListener('input', _refreshSaveOverwrite);
saveInput.addEventListener('keydown', e => {
  if (e.key === 'Enter') document.getElementById('btn-save-confirm').click();
  if (e.key === 'Escape') modalSave.classList.add('hidden');
});
document.getElementById('btn-save-cancel').addEventListener('click', () =>
  modalSave.classList.add('hidden'));

document.getElementById('btn-save-confirm').addEventListener('click', async () => {
  const name = saveInput.value.trim();
  if (!name) { saveInput.focus(); return; }
  const snap = flowSnapshot();
  snap.name        = name;
  snap.uuid        = (_savedFlows.find(f => f.name === name) || {}).uuid || undefined;
  snap.category    = _saveCategory;
  snap.tags        = _saveTags.slice();
  snap.description = document.getElementById('save-desc-input').value.trim();
  await fetch('/api/flow/save', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(snap)
  });
  _loadedFlow = { name, file: name.replace(/[^a-zA-Z0-9_\-]/g, '_'),
                  category: _saveCategory, tags: _saveTags.slice(),
                  description: snap.description };
  _updateFlowBadge();
  modalSave.classList.add('hidden');
});

// ── Load modal ────────────────────────────────────────────────────
const modalLoad    = document.getElementById('modal-load');
const flowListWrap = document.getElementById('flow-list-wrap');
let _allFlows = [], _activeTagFilter = null;

const SVG_EDIT  = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M10.5 2.5l3 3-7.5 7.5H3.5v-3z"/><path d="M8.5 4.5l3 3"/></svg>`;
const SVG_LOAD  = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 8h10"/><path d="M9 4.5l4 3.5-4 3.5"/></svg>`;
const SVG_TRASH = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M2.5 4.5h11M5.5 4.5V3h5v1.5M4.5 4.5l.8 8h5.4l.8-8"/></svg>`;
const SVG_CHECK = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2.5 8.5l3.5 3.5 7.5-8"/></svg>`;
const SVG_X     = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/></svg>`;

function _makeFlowCard(flow, opts = {}) {
  const card = document.createElement('div');
  card.className = 'flow-card';
  const tagsHtml = (flow.tags||[]).map(t =>
    `<span class="flow-tag">${t}</span>`).join('');
  const date = flow.saved_at ? flow.saved_at.split('T')[0] : '–';
  const isLoaded = _loadedFlow && _loadedFlow.file === flow.file;
  card.innerHTML = `
    <div class="flow-card-header">
      <div class="flow-card-name">${flow.name}${isLoaded ? ' <span style="font-size:9px;color:#4ade80;font-weight:400;letter-spacing:0.05em">● geöffnet</span>' : ''}</div>
      <div class="flow-card-actions">
        ${!opts.saveGrid ? `<button class="fc-icon-btn fc-rename" title="Umbenennen">${SVG_EDIT}</button>` : ''}
        ${!opts.saveGrid ? `<button class="fc-icon-btn danger fc-del" title="Löschen">${SVG_TRASH}</button>` : ''}
      </div>
    </div>
    ${flow.description ? `<div class="flow-card-desc">${flow.description}</div>` : ''}
    ${tagsHtml ? `<div class="flow-card-tags">${tagsHtml}</div>` : ''}
    <div class="flow-card-footer">
      <div class="flow-card-meta">${flow.saved_by||'–'} · ${date}</div>
      ${!opts.saveGrid ? `<button class="fc-load-btn fc-load">${SVG_LOAD} Laden</button>` : ''}
    </div>`;

  if (!opts.saveGrid) {
    card.querySelector('.fc-load').addEventListener('click', async () => {
      const data = await fetch('/api/flow/load/' + flow.file).then(r => r.json());
      restoreFlow(data);
      _loadedFlow = { name: flow.name, file: flow.file, category: flow.category,
                      tags: flow.tags||[], description: flow.description||'' };
      _updateFlowBadge();
      modalLoad.classList.add('hidden');
    });
    card.querySelector('.fc-del').addEventListener('click', async () => {
      if (!confirm(`"${flow.name}" löschen?`)) return;
      await fetch('/api/flow/delete/' + flow.file, { method: 'DELETE' });
      _allFlows = _allFlows.filter(f => f.file !== flow.file);
      if (_loadedFlow && _loadedFlow.file === flow.file) { _loadedFlow = null; _updateFlowBadge(); }
      _renderTagFilter(); _renderLoadGroups();
    });
    card.querySelector('.fc-rename').addEventListener('click', () => {
      const header = card.querySelector('.flow-card-header');
      let editCat  = flow.category || null;
      let editTags = [...(flow.tags||[])];
      header.innerHTML = `
        <input class="flow-modal-input fc-rename-input" value="${flow.name}"
          style="flex:1;padding:5px 9px;font-size:12px;min-width:0" />
        <div class="flow-card-actions">
          <button class="fc-icon-btn fc-edit-ok" style="color:#4ade80">${SVG_CHECK}</button>
          <button class="fc-icon-btn fc-edit-cancel">${SVG_X}</button>
        </div>`;
      // Category single-select
      const catWrap = document.createElement('div');
      catWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:5px;margin-top:8px;';
      const renderEditCat = () => {
        catWrap.innerHTML = '';
        [{ uuid: null, name: 'Keine' }, ..._allCategories].forEach(cat => {
          const sel = editCat === cat.uuid;
          const p   = document.createElement('button');
          p.className = 'fc-tag-pill' + (sel ? ' selected' : '');
          if (cat.uuid) { p.style.borderColor = _tagColor(cat.name); }
          if (sel && cat.uuid) { p.style.background = _tagColor(cat.name)+'33'; p.style.color = _tagColor(cat.name); }
          p.textContent = cat.name;
          p.addEventListener('click', () => { editCat = cat.uuid; renderEditCat(); });
          catWrap.appendChild(p);
        });
      };
      renderEditCat();
      // Free-form tag chips
      const tagWrap = document.createElement('div');
      tagWrap.className = 'fc-edit-tags';
      const renderEditTags = () => {
        tagWrap.innerHTML = '';
        editTags.forEach((t, i) => {
          const chip = document.createElement('span');
          chip.className = 'tag-chip';
          chip.style.fontSize = '10.5px';
          chip.innerHTML = `${t}<button class="tag-chip-remove" style="font-size:12px">×</button>`;
          chip.querySelector('.tag-chip-remove').addEventListener('click', () => { editTags.splice(i,1); renderEditTags(); });
          tagWrap.appendChild(chip);
        });
        const addBtn = document.createElement('button');
        addBtn.className = 'tag-chip-add'; addBtn.textContent = '+ Tag';
        addBtn.addEventListener('click', () => {
          const ti = document.createElement('input');
          ti.className = 'tag-chip-input'; ti.placeholder = 'Tag…';
          addBtn.replaceWith(ti); ti.focus();
          ti.addEventListener('keydown', ev => {
            if (ev.key === 'Enter' || ev.key === ',') { ev.preventDefault(); const v = ti.value.trim(); if (v && !editTags.includes(v)) editTags.push(v); renderEditTags(); }
            if (ev.key === 'Escape') renderEditTags();
          });
          ti.addEventListener('blur', () => { const v = ti.value.trim(); if (v && !editTags.includes(v)) editTags.push(v); renderEditTags(); });
        });
        tagWrap.appendChild(addBtn);
      };
      renderEditTags();
      header.after(catWrap); catWrap.after(tagWrap);
      const origTags = card.querySelector('.flow-card-tags');
      if (origTags) origTags.style.display = 'none';
      const inp = header.querySelector('.fc-rename-input');
      inp.focus(); inp.select();
      const doSave = async () => {
        const newName = inp.value.trim() || flow.name;
        const res = await fetch(`/api/flow/meta/${flow.file}`, {
          method: 'PATCH', headers: {'Content-Type':'application/json'},
          body: JSON.stringify({ name: newName, category: editCat, tags: editTags })
        }).then(r => r.json()).catch(() => null);
        if (res && res.ok) {
          const f = _allFlows.find(x => x.file === flow.file);
          if (f) { f.name = newName; f.file = res.file; f.category = editCat; f.tags = editTags; }
          if (_loadedFlow && _loadedFlow.file === flow.file) {
            _loadedFlow.name = newName; _loadedFlow.file = res.file;
            _loadedFlow.category = editCat; _loadedFlow.tags = editTags;
            _updateFlowBadge();
          }
        }
        _renderTagFilter(); _renderLoadGroups();
      };
      header.querySelector('.fc-edit-ok').addEventListener('click', doSave);
      header.querySelector('.fc-edit-cancel').addEventListener('click', () => _renderLoadGroups());
      inp.addEventListener('keydown', e => { if (e.key === 'Enter') doSave(); if (e.key === 'Escape') _renderLoadGroups(); });
    });
  }
  return card;
}

function _renderLoadGroups() {
  const search = (document.getElementById('load-search').value || '').toLowerCase();
  flowListWrap.innerHTML = '';
  let flows = _allFlows;
  if (search) flows = flows.filter(f =>
    (f.name||'').toLowerCase().includes(search) ||
    (f.description||'').toLowerCase().includes(search) ||
    (f.tags||[]).some(t => t.toLowerCase().includes(search)));
  if (_activeTagFilter)
    flows = flows.filter(f => (f.tags||[]).includes(_activeTagFilter));
  if (flows.length === 0) {
    flowListWrap.innerHTML = '<div class="flow-list-empty">Keine Flows gefunden</div>';
    return;
  }
  // Build ordered groups: categories in order, uncategorised last
  const groups = [];
  _allCategories.forEach(cat => {
    const members = flows.filter(f => f.category === cat.uuid);
    if (members.length) groups.push({ cat, flows: members });
  });
  const uncategorised = flows.filter(f => !f.category);
  if (uncategorised.length) groups.push({ cat: null, flows: uncategorised });

  groups.forEach(({ cat, flows: gFlows }) => {
    const section = document.createElement('div');
    section.className = 'flow-group';
    const hdr = document.createElement('div');
    hdr.className = cat ? 'flow-group-header' : 'flow-group-header uncategorized';
    if (cat) {
      hdr.style.borderBottomColor = _tagColor(cat.name) + '55';
    }
    hdr.textContent = cat ? cat.name : 'Ohne Gruppe';
    section.appendChild(hdr);
    const grid = document.createElement('div');
    grid.className = 'flow-card-grid';
    gFlows.forEach(flow => grid.appendChild(_makeFlowCard(flow)));
    section.appendChild(grid);
    flowListWrap.appendChild(section);
  });
}

function _renderTagFilter() {
  const allTags = [...new Set(_allFlows.flatMap(f => f.tags||[]))];
  const bar = document.getElementById('load-tag-filter');
  bar.innerHTML = '';
  if (!allTags.length) return;
  const mkPill = (label, value) => {
    const p = document.createElement('span');
    p.className = 'flow-tag-filter' + (_activeTagFilter === value ? ' active' : '');
    p.textContent = label;
    p.addEventListener('click', () => { _activeTagFilter = value; _renderTagFilter(); _renderLoadGroups(); });
    bar.appendChild(p);
  };
  mkPill('Alle', null);
  allTags.forEach(t => mkPill(t, t));
}

document.getElementById('btn-load').addEventListener('click', async () => {
  _activeTagFilter = null;
  _allFlows = await fetch('/api/flow/list').then(r => r.json()).catch(() => []);
  document.getElementById('load-search').value = '';
  document.getElementById('cat-mgmt-panel').classList.add('hidden');
  _renderTagFilter(); _renderLoadGroups();
  modalLoad.classList.remove('hidden');
});
document.getElementById('load-search').addEventListener('input', _renderLoadGroups);
document.getElementById('btn-load-cancel').addEventListener('click', () =>
  modalLoad.classList.add('hidden'));

// ── Category management panel ─────────────────────────────────────
function _renderCatMgmt() {
  const list = document.getElementById('cat-mgmt-list');
  list.innerHTML = '';
  const SVG_EDIT2  = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M10.5 2.5l3 3-7.5 7.5H3.5v-3z"/><path d="M8.5 4.5l3 3"/></svg>`;
  const SVG_TRASH2 = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M2.5 4.5h11M5.5 4.5V3h5v1.5M4.5 4.5l.8 8h5.4l.8-8"/></svg>`;
  const SVG_CHECK2 = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2.5 8.5l3.5 3.5 7.5-8"/></svg>`;
  const SVG_X2     = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/></svg>`;

  _allCategories.forEach(cat => {
    const row = document.createElement('div');
    row.className = 'cat-mgmt-row';
    row.innerHTML = `
      <div class="cat-mgmt-dot" style="background:${_tagColor(cat.name)}"></div>
      <div class="cat-mgmt-name">${cat.name}</div>
      <button class="fc-icon-btn cm-edit" title="Umbenennen">${SVG_EDIT2}</button>
      <button class="fc-icon-btn danger cm-del" title="Löschen">${SVG_TRASH2}</button>`;

    row.querySelector('.cm-edit').addEventListener('click', () => {
      row.innerHTML = `
        <input class="cat-mgmt-input cm-rename-inp" value="${cat.name}" style="flex:1" />
        <button class="fc-icon-btn cm-ok" style="color:#4ade80">${SVG_CHECK2}</button>
        <button class="fc-icon-btn cm-cancel">${SVG_X2}</button>`;
      const inp = row.querySelector('.cm-rename-inp');
      inp.focus(); inp.select();
      const doRename = async () => {
        const newName = inp.value.trim();
        if (!newName || newName === cat.name) { _renderCatMgmt(); return; }
        const res = await fetch(`/api/flow/categories/${cat.uuid}`, {
          method: 'PATCH', headers: {'Content-Type':'application/json'},
          body: JSON.stringify({ name: newName })
        }).then(r => r.json()).catch(() => null);
        if (res && res.ok) { cat.name = newName; _renderCatPills(); }
        _renderCatMgmt();
      };
      row.querySelector('.cm-ok').addEventListener('click', doRename);
      row.querySelector('.cm-cancel').addEventListener('click', () => _renderCatMgmt());
      inp.addEventListener('keydown', e => {
        if (e.key === 'Enter') doRename();
        if (e.key === 'Escape') _renderCatMgmt();
      });
    });

    row.querySelector('.cm-del').addEventListener('click', async () => {
      if (!confirm(`Gruppe "${cat.name}" löschen?`)) return;
      await fetch(`/api/flow/categories/${cat.uuid}`, { method: 'DELETE' });
      _allCategories = _allCategories.filter(c => c.uuid !== cat.uuid);
      if (_saveCategory === cat.uuid) { _saveCategory = null; _renderCatPills(); }
      _renderCatMgmt(); _renderTagFilter(); _renderLoadGroups();
    });

    list.appendChild(row);
  });
}

document.getElementById('btn-cat-mgmt').addEventListener('click', () => {
  const panel = document.getElementById('cat-mgmt-panel');
  panel.classList.toggle('hidden');
  if (!panel.classList.contains('hidden')) _renderCatMgmt();
});

async function _catMgmtAdd() {
  const inp = document.getElementById('cat-mgmt-new-input');
  const name = inp.value.trim();
  if (!name || _allCategories.some(c => c.name === name)) { inp.value = ''; return; }
  const res = await fetch('/api/flow/categories', {
    method: 'POST', headers: {'Content-Type':'application/json'},
    body: JSON.stringify({ name })
  }).then(r => r.json()).catch(() => null);
  if (res && res.category) _allCategories.push(res.category);
  inp.value = '';
  _renderCatMgmt();
}
document.getElementById('cat-mgmt-add-btn').addEventListener('click', _catMgmtAdd);
document.getElementById('cat-mgmt-new-input').addEventListener('keydown', e => {
  if (e.key === 'Enter') _catMgmtAdd();
});

// Clear
document.getElementById('btn-clear').addEventListener('click', () => {
  if (nodes.length === 0) return;
  if (confirm('Canvas leeren?')) {
    clearCanvas();
    _loadedFlow = null;
    _updateFlowBadge();
  }
});

// close modals on backdrop click
[modalSave, modalLoad].forEach(m => m.addEventListener('click', e => {
  if (e.target === m) m.classList.add('hidden');
}));

// ── Output Drawer ─────────────────────────────────────────────────

const outputDrawer = document.getElementById('output-drawer');
const odTabs       = document.getElementById('od-tabs');
const odBody       = document.getElementById('od-body');

document.getElementById('od-close').addEventListener('click', () => {
  outputDrawer.classList.remove('open');
  outputDrawer.style.height = '';
});

// Resize handle
(function() {
  const handle = document.getElementById('od-resize-handle');
  let startY, startH;
  handle.addEventListener('mousedown', e => {
    if (!outputDrawer.classList.contains('open')) return;
    startY = e.clientY;
    startH = outputDrawer.offsetHeight;
    outputDrawer.classList.add('resizing');
    handle.classList.add('dragging');
    e.preventDefault();
    function onMove(e) {
      const newH = Math.max(80, startH - (e.clientY - startY));
      outputDrawer.style.height = newH + 'px';
    }
    function onUp() {
      outputDrawer.classList.remove('resizing');
      handle.classList.remove('dragging');
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      window.dispatchEvent(new Event('resize'));
    }
    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onUp);
  });
})();

function renderOutputScanCheck(out) {
  const wrap = document.createElement('div');
  wrap.style.cssText = 'flex:1;overflow-y:auto;padding:10px 14px;font-size:10px;';

  // Summary header
  const hdr = document.createElement('div');
  hdr.style.cssText = 'margin-bottom:12px;color:rgba(255,255,255,0.45);font-size:9px;letter-spacing:0.08em;';
  hdr.textContent = `Test-ID: ${out.test_id}  ·  Engine: ${out.engine_type}`;
  wrap.appendChild(hdr);

  for (const [code, r] of Object.entries(out.results || {})) {
    const section = document.createElement('div');
    section.style.cssText = 'margin-bottom:14px;';

    // Code header
    const codeHdr = document.createElement('div');
    codeHdr.style.cssText = `font-size:10px;font-weight:600;margin-bottom:5px;`
      + `color:${r.ok ? '#22c55e' : '#ef4444'};`;
    codeHdr.textContent = `${code}  —  Scan ${r.scan_nr}  ${r.ok ? '✓ PASS' : '✗ FAIL'}`;
    section.appendChild(codeHdr);

    // Violations
    if (r.violations && r.violations.length) {
      const viol = document.createElement('div');
      viol.style.cssText = 'font-size:9px;color:#ef4444;margin-bottom:5px;';
      viol.textContent = r.violations.join('  ·  ');
      section.appendChild(viol);
    }

    // Values table
    const tbl = document.createElement('table');
    tbl.style.cssText = 'width:100%;border-collapse:collapse;font-size:9px;';
    for (const [ch, val] of Object.entries(r.values || {})) {
      if (ch.startsWith('_')) continue;  // skip internal fields like _timestamp
      const tr = document.createElement('tr');
      tr.innerHTML = `<td style="color:rgba(255,255,255,0.4);padding:1px 10px 1px 0;white-space:nowrap">${ch}</td>`
                   + `<td style="color:rgba(255,255,255,0.85);font-family:monospace">${val}</td>`;
      tbl.appendChild(tr);
    }
    section.appendChild(tbl);

    // Divider
    const div = document.createElement('div');
    div.style.cssText = 'border-top:1px solid rgba(255,255,255,0.07);margin-top:10px;';
    section.appendChild(div);

    wrap.appendChild(section);
  }
  return wrap;
}

function renderClusterCheck(out) {
  const wrap = document.createElement('div');
  wrap.style.cssText = 'padding:12px;font-size:12px;';
  const summary = document.createElement('div');
  summary.style.cssText = 'margin-bottom:12px;font-size:11px;color:rgba(255,255,255,0.5);';
  summary.textContent = `${out.passed} / ${out.total} Regeln bestanden  ·  ${out.failed} fehlgeschlagen`;
  wrap.appendChild(summary);
  for (const r of (out.results || [])) {
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;align-items:flex-start;gap:8px;margin-bottom:8px;padding:7px 10px;border-radius:5px;background:rgba(255,255,255,0.04);';
    const icon = r.passed === true ? '✓' : r.passed === false ? '✗' : '?';
    const color = r.passed === true ? '#22c55e' : r.passed === false ? '#ef4444' : '#f59e0b';
    row.innerHTML = `<span style="color:${color};font-weight:700;min-width:14px">${icon}</span>`
      + `<div style="flex:1;min-width:0;">`
      + `<div style="color:rgba(255,255,255,0.85);word-break:break-all">${r.key}</div>`
      + `<div style="font-size:10px;color:rgba(255,255,255,0.4);margin-top:2px">`
      + `${r.op} <b style="color:rgba(255,255,255,0.6)">${r.expected||''}</b>`
      + (r.actual ? ` · Ist: <b style="color:${color}">${r.actual}</b>` : '') + `</div></div>`;
    wrap.appendChild(row);
  }
  return wrap;
}

function openOutputDrawer(results) {
  const OUTPUT_TYPES = new Set(['display', 'cert_build', 'cipa_cert', 'check', 'plot', 'cluster_check', 'pdf_compose']);
  const displayNodes = nodes.filter(nd => OUTPUT_TYPES.has(nd.typeId) && results[nd.id]);
  if (displayNodes.length === 0) return;

  odTabs.innerHTML = '';
  odBody.innerHTML = '';

  displayNodes.forEach((nd, i) => {
    const res    = results[nd.id];
    const label  = nd.config.title || `Display ${i + 1}`;
    const hasErr = res.status !== 'success';

    const isScanCheck    = !hasErr && res.output && res.output.type === 'scan_check_result';
    const isClusterCheck = !hasErr && res.output && res.output.type === 'cluster_check_result';
    const scanOk         = isScanCheck ? res.output.overall_ok : (isClusterCheck ? res.output.failed === 0 : true);

    const isPdfResult  = !hasErr && res.output && res.output.__pdf_result__;
    const isCipaResult = !hasErr && res.output && res.output.__cipa_result__;

    const tab = document.createElement('div');
    tab.className = 'od-tab' + (i === 0 ? ' active' : '');
    tab.innerHTML = hasErr
      ? `<span class="od-tab-err">⚠</span> ${label}`
      : isScanCheck
        ? `✓ ${label} ${scanOk ? '<span style="color:#22c55e">PASS</span>' : '<span style="color:#ef4444">FAIL</span>'}`
        : (isPdfResult || isCipaResult)
          ? `⬇ ${label}`
          : `▤ ${label}`;

    const pane = document.createElement('div');
    pane.className      = 'od-pane';
    pane.dataset.paneId = nd.id;
    pane.style.display  = i === 0 ? 'flex' : 'none';

    if (hasErr) {
      const errdiv = document.createElement('pre');
      errdiv.className = 'cp-output-pre';
      errdiv.style.color = '#ef4444';
      errdiv.textContent = res.error || 'Fehler';
      pane.appendChild(errdiv);
    } else if (isScanCheck) {
      pane.appendChild(renderOutputScanCheck(res.output));
    } else if (isClusterCheck) {
      pane.appendChild(renderClusterCheck(res.output));
    } else if (isPdfResult) {
      pane.appendChild(renderOutputPdf(res.output));
    } else if (res.output && res.output.__plot__) {
      const plotDiv = document.createElement('div');
      plotDiv.style.cssText = 'width:100%;height:100%;';
      pane.appendChild(plotDiv);
      (function doPlot() {
        if (window.Plotly) {
          const d = res.output.plotly_json;
          Plotly.newPlot(plotDiv, d.data, d.layout,
                         {displayModeBar: true, scrollZoom: true, responsive: true});
        } else {
          const s = document.createElement('script');
          s.src = 'https://cdn.plot.ly/plotly-latest.min.js';
          s.onload = doPlot;
          document.head.appendChild(s);
        }
      })();
    } else if (res.output && res.output.__cipa_result__) {
      pane.appendChild(renderOutputCipaCert(res.output));
    } else if (res.output && res.output.__cert_result__) {
      pane.appendChild(renderOutputCert(res.output));
    } else {
      const dtype = nd.config.displayType || 'table';
      let content;
      if (dtype === 'table')      content = renderOutputTable(res.output);
      else if (dtype === 'chart') content = renderOutputChart(res.output);
      else                        content = renderOutputJson(res.output);
      pane.appendChild(content);
    }

    tab.addEventListener('click', () => {
      odTabs.querySelectorAll('.od-tab').forEach(t => t.classList.remove('active'));
      tab.classList.add('active');
      odBody.querySelectorAll('[data-pane-id]').forEach(p => p.style.display = 'none');
      pane.style.display = 'flex';
    });

    odTabs.appendChild(tab);
    odBody.appendChild(pane);
  });

  outputDrawer.style.height = '';
  outputDrawer.classList.add('open');
}

// ── run flow ──────────────────────────────────────────────────────

function clearExecState() {
  nodes.forEach(nd => {
    nd.el.classList.remove('exec-running', 'exec-success', 'exec-error', 'exec-skipped');
    const b = nd.el.querySelector('.node-exec-badge');
    if (b) b.remove();
    nd.output = null;
  });
}

function setNodeExecState(nodeId, state, label) {
  const nd = nodes.find(n => n.id === nodeId);
  if (!nd) return;
  nd.el.classList.remove('exec-running', 'exec-success', 'exec-error', 'exec-skipped');
  nd.el.classList.add('exec-' + state);
  let badge = nd.el.querySelector('.node-exec-badge');
  if (!badge) {
    badge = document.createElement('div');
    badge.className = 'node-exec-badge';
    nd.el.appendChild(badge);
  }
  badge.className = 'node-exec-badge ' + state;
  badge.textContent = label;
}

document.getElementById('btn-run').addEventListener('click', async () => {
  if (nodes.length === 0) return;
  const btn = document.getElementById('btn-run');
  btn.disabled = true;
  btn.textContent = '⏳ Running…';
  clearExecState();

  // mark all running
  nodes.forEach(nd => setNodeExecState(nd.id, 'running', 'running'));

  const payload = {
    nodes: nodes.map(nd => ({ id: nd.id, typeId: nd.typeId, config: nd.config })),
    connections: connections.map(c => ({
      fromNodeId: c.fromNodeId, fromPortIdx: c.fromPortIdx,
      toNodeId:   c.toNodeId,   toPortIdx:   c.toPortIdx
    }))
  };

  try {
    const resp = await fetch('/api/flow/run', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });
    const results = await resp.json();

    for (const [idStr, res] of Object.entries(results)) {
      const nodeId = parseInt(idStr);
      const nd     = nodes.find(n => n.id === nodeId);
      if (!nd) continue;
      if (res.status === 'success') {
        nd.output = res.output;
        if (nd.typeId === 'branch' && res.output) {
          const lbl = res.output.result === 'true' ? '→ true' : '→ false';
          setNodeExecState(nodeId, 'success', lbl);
        } else {
          setNodeExecState(nodeId, 'success', 'ok');
        }
      } else if (res.status === 'skipped') {
        nd.output = null;
        setNodeExecState(nodeId, 'skipped', '⊘ skip');
      } else {
        nd.output = { error: res.error };
        setNodeExecState(nodeId, 'error', 'error');
      }
      // refresh config panel if this node is selected
      if (selectedNode === nodeId) renderConfigPanel(nodeId);
    }
    openOutputDrawer(results);
  } catch (err) {
    nodes.forEach(nd => setNodeExecState(nd.id, 'error', 'error'));
  } finally {
    btn.disabled = false;
    btn.textContent = '▶ Run';
  }
});

// ── service status polling ────────────────────────────────────────

async function pollStatus() {
  try {
    const s = await fetch('/api/flow/status').then(r => r.json());
    document.getElementById('svc-tardis').className = 'svc-dot ' + (s.tardis ? 'ok' : 'err');
    document.getElementById('svc-hb').className     = 'svc-dot ' + (s.hyperboost ? 'ok' : 'err');
  } catch (_) {}
}
pollStatus();
setInterval(pollStatus, 600000);

updateEmptyHint();
})();
</script>

<script>
// ══════════════════════════════════════════════════════════════════
// SETTINGS OVERLAY  (global scope – accessible from inline onclick)
// ══════════════════════════════════════════════════════════════════

function toggleSettings() {
  const ov  = document.getElementById('settings-overlay');
  const btn = document.getElementById('btn-settings');
  const isOpen = !ov.classList.contains('hidden');
  ov.classList.toggle('hidden', isOpen);
  btn.classList.toggle('active', !isOpen);
  if (!isOpen) {
    switchSettingsSection('connections');
  }
}

function switchSettingsSection(name) {
  document.querySelectorAll('.sn-item').forEach(el =>
    el.classList.toggle('active', el.dataset.section === name));
  document.querySelectorAll('.sset').forEach(el =>
    el.classList.toggle('active', el.id === 'sset-' + name));
  const sc = document.getElementById('settings-content');
  sc.classList.toggle('recipes-active',        name === 'recipes');
  sc.classList.toggle('engine-mappings-active', name === 'engine-mappings');
  sc.classList.toggle('step-templates-active',  name === 'step-templates');
  sc.classList.toggle('cipa-config-active',     name === 'cipa-config');
  if (name === 'connections')     renderSettingsConnections();
  if (name === 'recipes')         renderSettingsRecipes();
  if (name === 'engine-mappings') renderSettingsEngineMappings();
  if (name === 'step-templates')  renderSettingsStepTemplates();
  if (name === 'node-defaults')   renderSettingsNodeDefaults();
  if (name === 'cipa-config')     renderSettingsCipaConfig();
}

// Visual-only section switch — used when clicking a node while settings is open.
// Does NOT trigger render functions to avoid destroying already-loaded content.
function _switchSettingsSectionSilent(name) {
  document.querySelectorAll('.sn-item').forEach(el =>
    el.classList.toggle('active', el.dataset.section === name));
  document.querySelectorAll('.sset').forEach(el =>
    el.classList.toggle('active', el.id === 'sset-' + name));
  const sc = document.getElementById('settings-content');
  sc.classList.toggle('recipes-active',        name === 'recipes');
  sc.classList.toggle('engine-mappings-active', name === 'engine-mappings');
  sc.classList.toggle('step-templates-active',  name === 'step-templates');
  sc.classList.toggle('cipa-config-active',     name === 'cipa-config');
  // Only trigger render if the section was never loaded before
  const sec = document.getElementById('sset-' + name);
  if (sec && !sec.dataset.loaded) switchSettingsSection(name);
}

// ── Connections section ───────────────────────────────────────────
async function renderSettingsConnections() {
  const container = document.getElementById('sset-connections');
  if (container.dataset.loaded) return;  // already rendered, keep edits
  container.dataset.loaded = '1';

  let saved = {};
  try { const r = await fetch('/api/settings'); saved = (await r.json()).connections || {}; } catch(_) {}

  const TARDIS_FIELDS = [
    { key: 'TARDIS_USER',     label: 'User',           type: 'text',     placeholder: 'z.B. yj13214' },
    { key: 'TARDIS_HOST',     label: 'Host',           type: 'text',     placeholder: 'z.B. test' },
    { key: 'TARDIS_TOKEN',    label: 'API Token',      type: 'password', placeholder: 'Leer lassen = unverändert' },
    { key: 'TARDIS_SOURCE',   label: 'Source',         type: 'text',     placeholder: 'z.B. TEST_ENG_CIV' },
  ];
  const HB_FIELDS = [
    { key: 'HB_BASE',         label: 'Base URL',       type: 'text',     placeholder: 'http://host:port/api/v1/tms' },
    { key: 'HB_USER',         label: 'User',           type: 'text',     placeholder: 'z.B. pst4' },
    { key: 'HB_PASSWORD',     label: 'Password',       type: 'password', placeholder: 'Leer lassen = unverändert' },
    { key: 'HB_TESTBED',      label: 'Testbed',        type: 'text',     placeholder: 'z.B. MTU-L-4' },
  ];

  function buildGroup(title, fields) {
    const g = document.createElement('div'); g.className = 'ss-group';
    const h = document.createElement('div'); h.className = 'ss-group-title'; h.textContent = title;
    g.appendChild(h);
    fields.forEach(f => {
      const fd = document.createElement('div'); fd.className = 'ss-field';
      const lb = document.createElement('div'); lb.className = 'ss-label'; lb.textContent = f.label;
      const inp = document.createElement('input'); inp.className = 'ss-input';
      inp.type = f.type; inp.placeholder = f.placeholder; inp.dataset.key = f.key;
      inp.value = saved[f.key] || '';
      fd.appendChild(lb); fd.appendChild(inp); g.appendChild(fd);
    });
    return g;
  }

  container.innerHTML = '';
  const title = document.createElement('div'); title.className = 'ss-title'; title.textContent = 'Verbindungen';
  const sub   = document.createElement('div'); sub.className   = 'ss-subtitle';
  sub.textContent = 'Gespeichert in settings.json — überschreibt die Standardwerte beim nächsten Start.';
  container.appendChild(title); container.appendChild(sub);
  container.appendChild(buildGroup('TARDIS / OpenMDM', TARDIS_FIELDS));
  container.appendChild(buildGroup('HyperBoost', HB_FIELDS));

  const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:12px;margin-top:8px;';
  const btn = document.createElement('button'); btn.className = 'ss-save-btn'; btn.textContent = '💾 Speichern';
  const status = document.createElement('span'); status.className = 'ss-status';
  row.appendChild(btn); row.appendChild(status); container.appendChild(row);

  btn.addEventListener('click', async () => {
    const payload = { connections: {} };
    container.querySelectorAll('.ss-input').forEach(inp => {
      if (inp.value.trim()) payload.connections[inp.dataset.key] = inp.value.trim();
    });
    btn.disabled = true; btn.textContent = 'Speichere…';
    try {
      const r = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
      const d = await r.json();
      status.textContent = d.ok ? '✓ Gespeichert' : '✗ ' + d.error;
      status.style.color = d.ok ? '#22c55e' : '#ef4444';
      setTimeout(() => status.textContent = '', 3000);
    } catch(e) { status.textContent = '✗ ' + e.message; status.style.color = '#ef4444'; }
    finally { btn.disabled = false; btn.textContent = '💾 Speichern'; }
  });
}

// ── Engine Mapping Editor (Settings) ─────────────────────────────
let emMappings = [];
let emSelIdx   = null;
let emSubtab   = 'pdf';  // 'pdf' | 'cluster' | 'const'

async function renderSettingsEngineMappings() {
  const sec = document.getElementById('sset-engine-mappings');
  if (sec.dataset.loaded) return;
  sec.dataset.loaded = '1';
  sec.innerHTML = `
    <div class="re-sidebar">
      <div style="padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
        <span style="font-size:11px;letter-spacing:0.15em;text-transform:uppercase;color:var(--text-dim)">Engine Types</span>
        <button class="re-icon-btn" id="em-add-btn" title="Neues Mapping">+</button>
      </div>
      <div id="em-list" class="re-engine-list"></div>
      <div class="re-footer">
        <button class="re-save-btn" id="em-save-btn">💾 Speichern</button>
        <span id="em-save-status" style="font-size:11px;color:var(--text-dim)"></span>
      </div>
    </div>
    <div class="re-editor" id="em-editor">
      <div class="empty-state">
        <div class="empty-icon">◈</div>
        <div>Engine Mapping links auswählen oder neu erstellen</div>
      </div>
    </div>`;

  try {
    const r = await fetch('/api/engine-mappings');
    emMappings = await r.json();
  } catch(_) { emMappings = []; }
  emSelIdx = null;

  document.getElementById('em-add-btn').addEventListener('click', () => {
    emMappings.push({ id: 'em_' + Date.now(), engine_type: 'Neuer Typ', pdf_prefix: '',
                      wizard_mappings: [] });
    emSelIdx = emMappings.length - 1;
    emRenderList(); emRenderEditor();
  });

  document.getElementById('em-save-btn').addEventListener('click', async () => {
    const btn = document.getElementById('em-save-btn');
    const st  = document.getElementById('em-save-status');
    btn.disabled = true; btn.textContent = 'Speichere…';
    try {
      const r = await fetch('/api/engine-mappings', {
        method: 'POST', headers: {'Content-Type':'application/json'},
        body: JSON.stringify(emMappings)
      });
      const d = await r.json();
      st.textContent = d.ok ? '✓ Gespeichert' : '✗ ' + d.error;
      st.style.color = d.ok ? '#22c55e' : '#ef4444';
      setTimeout(() => st.textContent = '', 3000);
    } catch(e) { st.textContent = '✗ ' + e.message; st.style.color = '#ef4444'; }
    finally { btn.disabled = false; btn.textContent = '💾 Speichern'; }
  });

  emRenderList();
}

function emRenderList() {
  const list = document.getElementById('em-list');
  list.innerHTML = '';
  emMappings.forEach((m, i) => {
    const item = document.createElement('div');
    item.className = 're-engine-item' + (i === emSelIdx ? ' selected' : '');
    const wizCount = (m.wizard_mappings || []).length;
    const hbCount  = (m.pdf_config?.field_labels || []).length;
    item.innerHTML = `
      <span class="re-engine-item-name">${m.engine_type || '—'}</span>
      <span style="font-size:10px;color:var(--text-dim)">${wizCount}w ${hbCount}hb</span>
      <button class="re-del-btn" data-i="${i}" title="Löschen">✕</button>`;
    item.addEventListener('click', e => {
      if (e.target.classList.contains('re-del-btn')) {
        emMappings.splice(parseInt(e.target.dataset.i), 1);
        if (emSelIdx >= emMappings.length) emSelIdx = emMappings.length - 1;
        emRenderList(); emRenderEditor(); return;
      }
      emSelIdx = i; emRenderList(); emRenderEditor();
    });
    list.appendChild(item);
  });
}

function emRenderEditor() {
  const ed = document.getElementById('em-editor');
  if (emSelIdx === null || emSelIdx < 0 || emSelIdx >= emMappings.length) {
    ed.innerHTML = '<div class="empty-state"><div class="empty-icon">◈</div><div>Engine Mapping links auswählen oder neu erstellen</div></div>';
    return;
  }
  const m = emMappings[emSelIdx];
  if (!m.wizard_mappings) m.wizard_mappings = [];

  ed.innerHTML = `
    <datalist id="em-dl-tip">${TIP_DEFAULTS.map(k=>`<option value="${k}">`).join('')}</datalist>
    <datalist id="em-dl-meta">${METADATA_FIELDS.map(k=>`<option value="${k}">`).join('')}</datalist>
    <div class="re-engine-header">
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
        <div class="re-field">
          <div class="re-label">Engine Type (Anzeigename)</div>
          <input class="re-input" id="em-engine-type" value="${(m.engine_type||'').replace(/"/g,'&quot;')}" placeholder="z.B. CFM56-7B">
        </div>
        <div class="re-field">
          <div class="re-label">PDF Prefix <span style="color:var(--text-dim);font-weight:400">(Erkennung)</span></div>
          <input class="re-input" id="em-pdf-prefix" value="${(m.pdf_prefix||'').replace(/"/g,'&quot;')}" placeholder="z.B. CFM56-7B"
            title="Engine-String aus PDF beginnt mit diesem Wert → Mapping wird automatisch gewählt">
        </div>
        <div class="re-field">
          <div class="re-label">Engine Type API-Wert <span style="color:var(--text-dim);font-weight:400">(an HB gesendet)</span></div>
          <input class="re-input" id="em-engine-type-const" value="${(m.engine_type_const||'').replace(/"/g,'&quot;')}" placeholder="z.B. CFM56-7B"
            title="Sauberer Wert für die HB-API — überschreibt den rohen PDF-Wert (z.B. CFM56-7B26E → CFM56-7B)">
        </div>
        <div class="re-field">
          <div class="re-label">Testcell</div>
          <input class="re-input" id="em-testcell" value="${(m.testcell||'').replace(/"/g,'&quot;')}" placeholder="z.B. MTU-L-4">
        </div>
        <div class="re-field">
          <div class="re-label">Test Type</div>
          <input class="re-input" id="em-test-type" value="${(m.test_type_const||'').replace(/"/g,'&quot;')}" placeholder="z.B. MRO">
        </div>
      </div>
    </div>
    <div style="font-size:10px;color:var(--text-dim);background:rgba(13,148,136,0.06);border:1px solid rgba(13,148,136,0.15);padding:8px 12px;border-radius:6px;line-height:1.8;margin-bottom:4px;">
      ◈ <b>PDF Prefix:</b> "CFM56-7B26E" im PDF → Prefix "CFM56-7B" matcht dieses Mapping.<br>
      ◇ <b>HB Attribut:</b> Ist gleichzeitig der interne Feldname in kopfdaten — z.B. WO_NUMBER, ESN, WORKSCOPE …
    </div>
    <div class="re-subtabs">
      <button class="re-subtab${emSubtab==='pdf'?' active':''}"         data-tab="pdf">PDF Header → HB Attribute (${(m.pdf_config?.field_labels||[]).length})</button>
      <button class="re-subtab${emSubtab==='cluster'?' active':''}"     data-tab="cluster">PDF Cluster → HB Konstanten (${(m.cluster_mappings||[]).length})</button>
      <button class="re-subtab${emSubtab==='const'?' active':''}"       data-tab="const">Konstanten → HB Attribute (${(m.const_mappings||[]).length})</button>
    </div>
    <div id="em-subtab-body"></div>`;

  // sync alle Header-Felder live
  document.getElementById('em-engine-type').addEventListener('input', e => {
    emMappings[emSelIdx].engine_type = e.target.value; emRenderList();
  });
  document.getElementById('em-pdf-prefix').addEventListener('input', e => {
    emMappings[emSelIdx].pdf_prefix = e.target.value;
  });
  document.getElementById('em-engine-type-const').addEventListener('input', e => {
    emMappings[emSelIdx].engine_type_const = e.target.value;
  });
  document.getElementById('em-testcell').addEventListener('input', e => {
    emMappings[emSelIdx].testcell = e.target.value;
  });
  document.getElementById('em-test-type').addEventListener('input', e => {
    emMappings[emSelIdx].test_type_const = e.target.value;
  });

  // subtab switching
  ed.querySelectorAll('.re-subtab').forEach(btn => btn.addEventListener('click', () => {
    emSubtab = btn.dataset.tab; emRenderEditor();
  }));

  emRenderMappingTable();
}

function emRenderMappingTable() {
  const body = document.getElementById('em-subtab-body');
  if (!body) return;
  const m = emMappings[emSelIdx];

  if (emSubtab === 'pdf')     { emRenderPdfConfig(body, m); return; }
  if (emSubtab === 'cluster') { emRenderClusterMappings(body, m); return; }
  if (emSubtab === 'const')   { emRenderConstMappings(body, m); return; }
  // fallback: show pdf tab
  emRenderPdfConfig(body, m);
}

function emRenderClusterMappings(body, m) {
  if (!m.cluster_mappings) m.cluster_mappings = [];
  if (!m.pdf_config) m.pdf_config = {};
  body.innerHTML = '';

  // ── Cluster Keyword ───────────────────────────────────────────────
  const ckWrap = document.createElement('div'); ckWrap.style.cssText = 'margin-bottom:14px;';
  const ckLbl  = document.createElement('div'); ckLbl.className = 're-label';
  ckLbl.style.marginBottom = '2px';
  ckLbl.textContent = 'Cluster Keyword (identifiziert die Cluster-Tabelle im PDF)';
  const ckHint = document.createElement('div');
  ckHint.style.cssText = 'font-size:10px;color:var(--text-dim);margin-bottom:6px;';
  ckHint.textContent = 'Erstes Wort der Cluster-Tabelle im PDF (z.B. "cluster"). Die Suche beginnt erst ab der Seite des Headers — Tabellen davor werden ignoriert.';
  const ckInp = document.createElement('input'); ckInp.className = 're-input';
  ckInp.style.width = '60%'; ckInp.placeholder = 'z.B. cluster';
  ckInp.value = m.pdf_config.cluster_keyword || '';
  ckInp.addEventListener('input', () => { m.pdf_config.cluster_keyword = ckInp.value.trim(); });
  ckWrap.appendChild(ckLbl); ckWrap.appendChild(ckHint); ckWrap.appendChild(ckInp);
  body.appendChild(ckWrap);

  // ── Mapping-Tabelle ───────────────────────────────────────────────
  const hint = document.createElement('div');
  hint.style.cssText = 'font-size:10px;color:var(--text-dim);margin-bottom:8px;line-height:1.7;';
  hint.innerHTML = '🔗 <b>Cluster Key:</b> exakter Schlüssel aus dem cluster-Objekt (z.B. "General Information | Thrust Rating")<br>'
    + '➜ <b>HB Attribut:</b> Custom Attribute Name in HyperBoost — TIP-Key-Vorschläge per Autocomplete verfügbar';
  body.appendChild(hint);

  const table = document.createElement('table'); table.className = 'em-table';
  table.innerHTML = `<thead><tr><th>Cluster Key (aus PDF)</th><th>HB Attribut Name</th><th style="width:28px"></th></tr></thead>`;
  const tbody = document.createElement('tbody'); table.appendChild(tbody);

  function renderCMRow(cm, idx) {
    const tr = document.createElement('tr');
    tr.innerHTML = `
      <td><input class="em-td-input" data-k="cluster_key" data-i="${idx}"
          value="${(cm.cluster_key||'').replace(/"/g,'&quot;')}" placeholder="General Information | Thrust Rating"></td>
      <td><input class="em-td-input" data-k="hb_attr" data-i="${idx}"
          value="${(cm.hb_attr||'').replace(/"/g,'&quot;')}" placeholder="THRUST_RATING" list="em-dl-tip"></td>
      <td><button class="em-del-row" data-i="${idx}">✕</button></td>`;
    tr.querySelectorAll('[data-k]').forEach(el => el.addEventListener('input', e => {
      m.cluster_mappings[parseInt(e.target.dataset.i)][e.target.dataset.k] = e.target.value;
    }));
    tr.querySelector('.em-del-row').addEventListener('click', e => {
      m.cluster_mappings.splice(parseInt(e.currentTarget.dataset.i), 1);
      emRenderMappingTable(); emRenderList();
    });
    return tr;
  }

  m.cluster_mappings.forEach((cm, idx) => tbody.appendChild(renderCMRow(cm, idx)));
  body.appendChild(table);

  const addBtn = document.createElement('button'); addBtn.className = 'em-add-row';
  addBtn.textContent = '+ Cluster Mapping hinzufügen';
  addBtn.addEventListener('click', () => {
    m.cluster_mappings.push({ cluster_key: '', hb_attr: '' });
    emRenderMappingTable(); emRenderList();
  });
  body.appendChild(addBtn);
}

function emRenderConstMappings(body, m) {
  if (!m.const_mappings) m.const_mappings = [];
  body.innerHTML = '';

  const hint = document.createElement('div');
  hint.style.cssText = 'font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.7;';
  hint.innerHTML = '▸ <b>Fester Wert:</b> wird immer so an HyperBoost gesendet — unabhängig von der PDF.<br>'
    + 'Ideal für Hardware-Konstanten wie SPTE, UUT, Standard-Name oder andere fixe Konfiguration.';
  body.appendChild(hint);

  const table = document.createElement('table'); table.className = 'em-table';
  table.innerHTML = `<thead><tr><th>HB Attribut Name</th><th>Fester Wert</th><th style="width:28px"></th></tr></thead>`;
  const tbody = document.createElement('tbody'); table.appendChild(tbody);

  function renderConstRow(cm, idx) {
    const tr = document.createElement('tr');
    tr.innerHTML = `
      <td><input class="em-td-input" data-k="hb_attr" data-i="${idx}"
          value="${(cm.hb_attr||'').replace(/"/g,'&quot;')}" placeholder="z.B. spte-name" list="em-dl-tip"></td>
      <td><input class="em-td-input" data-k="value" data-i="${idx}"
          value="${(cm.value||'').replace(/"/g,'&quot;')}" placeholder="z.B. CFM56-7B-SPTE-1"></td>
      <td><button class="em-del-row" data-i="${idx}">✕</button></td>`;
    tr.querySelectorAll('[data-k]').forEach(el => el.addEventListener('input', e => {
      m.const_mappings[parseInt(e.target.dataset.i)][e.target.dataset.k] = e.target.value;
    }));
    tr.querySelector('.em-del-row').addEventListener('click', e => {
      m.const_mappings.splice(parseInt(e.currentTarget.dataset.i), 1);
      emRenderMappingTable(); emRenderList();
    });
    return tr;
  }

  m.const_mappings.forEach((cm, idx) => tbody.appendChild(renderConstRow(cm, idx)));
  body.appendChild(table);

  const addBtn = document.createElement('button'); addBtn.className = 'em-add-row';
  addBtn.textContent = '+ Konstante hinzufügen';
  addBtn.addEventListener('click', () => {
    m.const_mappings.push({ hb_attr: '', value: '' });
    emRenderMappingTable(); emRenderList();
  });
  body.appendChild(addBtn);
}

function emRenderPdfConfig(body, m) {
  if (!m.pdf_config) m.pdf_config = {};
  const pc = m.pdf_config;
  if (!Array.isArray(pc.field_labels)) pc.field_labels = [];

  body.innerHTML = '';

  // ── Header Keywords ──────────────────────────────────────────────
  const hkWrap = document.createElement('div'); hkWrap.style.cssText = 'margin-bottom:14px;';
  const hkLbl  = document.createElement('div'); hkLbl.className = 're-label';
  hkLbl.textContent = 'Header Keywords (kommagetrennt — identifizieren die Kopf-Tabelle im PDF)';
  const hkInp  = document.createElement('input'); hkInp.className = 're-input';
  hkInp.style.width = '100%';
  hkInp.placeholder = 'z.B. esn:, testrun & shipment';
  hkInp.value = (pc.header_keywords || []).join(', ');
  hkInp.addEventListener('input', () => {
    pc.header_keywords = hkInp.value.split(',').map(s => s.trim()).filter(Boolean);
  });
  hkWrap.appendChild(hkLbl); hkWrap.appendChild(hkInp); body.appendChild(hkWrap);

  // ── Header Suchbegriffe (field_labels) ───────────────────────────
  const flLbl = document.createElement('div'); flLbl.className = 're-label';
  flLbl.style.marginBottom = '2px';
  flLbl.textContent = 'PDF-Felder → HB Attribute (Single Source of Truth)';
  const flHint = document.createElement('div');
  flHint.style.cssText = 'font-size:10px;color:var(--text-dim);margin-bottom:6px;';
  flHint.textContent = 'Jede Zeile: PDF-Label (z.B. "esn:") → HB Attribut (z.B. "ESN"). Der HB-Key ist gleichzeitig der interne Feldname in kopfdaten.';
  body.appendChild(flLbl); body.appendChild(flHint);

  const table = document.createElement('table'); table.className = 'em-table';
  table.innerHTML = `<thead><tr>
    <th>PDF-Label (Suchwort)</th><th>HB Attribut</th><th>Modus</th><th style="width:28px"></th>
  </tr></thead>`;
  const tbody = document.createElement('tbody'); table.appendChild(tbody);

  function renderFlRow(fl, idx) {
    const tr = document.createElement('tr');
    const modeOptions = ['standard','engine_cell'].map(o =>
      `<option value="${o}"${fl.mode===o?' selected':''}>${o}</option>`).join('');
    tr.innerHTML = `
      <td><input class="em-td-input" data-k="label" data-i="${idx}"
          value="${(fl.label||'').replace(/"/g,'&quot;')}" placeholder="z.B. esn:"></td>
      <td><input class="em-td-input" data-k="field" data-i="${idx}"
          value="${(fl.field||'').replace(/"/g,'&quot;')}" placeholder="z.B. ESN"></td>
      <td><select class="em-td-input" style="padding:3px 4px" data-k="mode" data-i="${idx}">${modeOptions}</select></td>
      <td><button class="em-del-row" data-i="${idx}">✕</button></td>`;
    tr.querySelectorAll('[data-k]').forEach(el => {
      const ev = el.tagName === 'SELECT' ? 'change' : 'input';
      el.addEventListener(ev, e => {
        const i2 = parseInt(e.target.dataset.i);
        pc.field_labels[i2][e.target.dataset.k] = e.target.value;
      });
    });
    tr.querySelector('.em-del-row').addEventListener('click', e => {
      pc.field_labels.splice(parseInt(e.currentTarget.dataset.i), 1);
      emRenderMappingTable();
    });
    return tr;
  }

  pc.field_labels.forEach((fl, idx) => tbody.appendChild(renderFlRow(fl, idx)));
  body.appendChild(table);

  const addBtn = document.createElement('button'); addBtn.className = 'em-add-row';
  addBtn.textContent = '+ Feld hinzufügen';
  addBtn.addEventListener('click', () => {
    pc.field_labels.push({ field: '', label: '', mode: 'standard' });
    emRenderMappingTable();
  });
  body.appendChild(addBtn);

  // ── Prefill with defaults button ─────────────────────────────────
  if (!pc.field_labels.length && !pc.header_keywords) {
    const hint = document.createElement('div');
    hint.style.cssText = 'margin-top:12px;font-size:10px;color:var(--text-dim);';
    hint.innerHTML = '◈ Noch keine Konfiguration — ';
    const prefill = document.createElement('button');
    prefill.style.cssText = 'background:none;border:none;color:var(--accent);font-size:10px;cursor:pointer;padding:0;';
    prefill.textContent = 'WRB-Defaults übernehmen';
    prefill.addEventListener('click', () => {
      m.pdf_config = {
        header_keywords: ['esn:', 'testrun & shipment'],
        cluster_keyword:  'cluster',
        field_labels: [
          { field: 'ENGINE_TYPE', label: 'testrun & shipment', mode: 'engine_cell' },
          { field: 'ESN',         label: 'esn:',               mode: 'standard' },
          { field: 'TSN',         label: 'tsn:',               mode: 'standard' },
          { field: 'CSN',         label: 'csn',                mode: 'standard' },
          { field: 'WO_NUMBER',   label: 'mtu-wbs:',           mode: 'standard' },
          { field: 'CUS_SP',      label: 'cus (sp):',          mode: 'standard' },
        ],
      };
      emRenderMappingTable();
    });
    hint.appendChild(prefill); body.appendChild(hint);
  }
}

// ── Procedure / Step type constants ──────────────────────────────
const PROC_TYPES = [
  '6.0 STANDARD PROCEDURES', '0.0 POT Setup and Finalisation',
  '7.2 PRE-ACCEPTANCE', '7.3 RUN-IN', '7.4 ACCEPTANCE',
  '7.5 POST-ACCEPTANCE', 'ACTIONS',
];
const STEP_TYPES = [
  '0.0 POT Setup & Finalisation',
  '6.0 Standard Procedures',
  '6.1 Secure Procedure',
  '6.2 REST Action',
  '6.3 Create ELP Action',
  '6.4 DSL Action',
];

// ── Procedure Builder (shared, used by Step Templates editor) ────────
function renderProcedureBuilder(field, nd, container) {
  if (!Array.isArray(nd.config[field.key])) nd.config[field.key] = [];
  const procs = nd.config[field.key];

  function uid() { return 'p' + Math.random().toString(36).slice(2, 9); }

  function buildProcTypeSelect(val) {
    const sel = document.createElement('select'); sel.className = 'pb-select';
    PROC_TYPES.forEach(t => {
      const o = document.createElement('option'); o.value = t; o.textContent = t;
      if (t === val) o.selected = true; sel.appendChild(o);
    });
    return sel;
  }
  function buildStepTypeSelect(val) {
    const sel = document.createElement('select'); sel.className = 'pb-select';
    STEP_TYPES.forEach(t => {
      const o = document.createElement('option'); o.value = t; o.textContent = t;
      if (t === val) o.selected = true; sel.appendChild(o);
    });
    return sel;
  }

  function renderStep(proc, si) {
    const step = proc.steps[si];
    const row  = document.createElement('div'); row.className = 'pb-step-row';

    const titleInp = document.createElement('input'); titleInp.className = 'pb-input';
    titleInp.placeholder = 'Schritt-Bezeichnung'; titleInp.value = step.title || '';
    titleInp.addEventListener('input', () => { step.title = titleInp.value; });
    row.appendChild(titleInp);

    const bottom = document.createElement('div'); bottom.className = 'pb-step-bottom';
    const typeSel = buildStepTypeSelect(step.type);
    typeSel.style.flex = '1';
    typeSel.addEventListener('change', () => { step.type = typeSel.value; });

    const del = document.createElement('button'); del.className = 'pb-del'; del.textContent = '✕';
    del.title = 'Schritt löschen';
    del.addEventListener('click', () => { proc.steps.splice(si, 1); redraw(); });

    bottom.appendChild(typeSel); bottom.appendChild(del);
    row.appendChild(bottom);
    return row;
  }

  function renderProc(pi) {
    const proc = procs[pi];
    const card = document.createElement('div'); card.className = 'pb-proc';

    const hdr = document.createElement('div'); hdr.className = 'pb-proc-hdr';
    const top = document.createElement('div'); top.className = 'pb-proc-hdr-top';
    const num = document.createElement('span'); num.className = 'pb-proc-num';
    num.textContent = 'P-' + (pi + 1);

    const nameInp = document.createElement('input'); nameInp.className = 'pb-input';
    nameInp.placeholder = 'Name (z.B. TP-0001)'; nameInp.value = proc.name || '';
    nameInp.addEventListener('input', () => { proc.name = nameInp.value; });

    const del = document.createElement('button'); del.className = 'pb-del'; del.textContent = '✕';
    del.title = 'Procedure löschen';
    del.addEventListener('click', () => { procs.splice(pi, 1); redraw(); });

    top.appendChild(num); top.appendChild(nameInp); top.appendChild(del);
    hdr.appendChild(top);

    const typeSel = buildProcTypeSelect(proc.procedure_type_name);
    typeSel.style.width = '100%';
    typeSel.addEventListener('change', () => { proc.procedure_type_name = typeSel.value; });
    hdr.appendChild(typeSel);
    card.appendChild(hdr);

    const body = document.createElement('div'); body.className = 'pb-proc-body';
    const titleRow = document.createElement('div'); titleRow.className = 'pb-proc-title-row';
    const titleInp = document.createElement('input'); titleInp.className = 'pb-input';
    titleInp.placeholder = 'Titel / Bezeichnung'; titleInp.value = proc.title || '';
    titleInp.addEventListener('input', () => { proc.title = titleInp.value; });
    titleRow.appendChild(titleInp);
    body.appendChild(titleRow);

    const stepsLbl = document.createElement('div'); stepsLbl.className = 'pb-steps-label';
    stepsLbl.textContent = 'Steps';
    body.appendChild(stepsLbl);

    if (!Array.isArray(proc.steps)) proc.steps = [];
    proc.steps.forEach((_, si) => body.appendChild(renderStep(proc, si)));

    const addStep = document.createElement('button'); addStep.className = 'pb-add-step';
    addStep.textContent = '+ Step hinzufügen';
    addStep.addEventListener('click', () => {
      proc.steps.push({ id: uid(), title: '', type: STEP_TYPES[0], description: '' });
      redraw();
    });
    body.appendChild(addStep);
    card.appendChild(body);
    return card;
  }

  function redraw() {
    wrap.innerHTML = '';
    procs.forEach((_, pi) => wrap.appendChild(renderProc(pi)));
    const addBtn = document.createElement('button'); addBtn.className = 'pb-add-proc';
    addBtn.textContent = '+ Procedure hinzufügen';
    addBtn.addEventListener('click', () => {
      procs.push({ id: uid(), name: `TP-${String(procs.length + 1).padStart(4,'0')}`,
                   title: '', procedure_type_name: PROC_TYPES[0], description: '', steps: [] });
      redraw();
    });
    wrap.appendChild(addBtn);
  }

  const wrap = document.createElement('div'); wrap.className = 'pb-wrap';
  container.appendChild(wrap);
  redraw();
}

// ── Step Templates (Settings) ────────────────────────────────────
let stTemplates = [];
let stSelIdx    = null;

async function renderSettingsStepTemplates() {
  const sec = document.getElementById('sset-step-templates');
  if (sec.dataset.loaded) return;
  sec.dataset.loaded = '1';
  sec.innerHTML = `
    <div class="re-sidebar">
      <div style="padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
        <span style="font-size:11px;letter-spacing:0.15em;text-transform:uppercase;color:var(--text-dim)">Templates</span>
        <button class="re-icon-btn" id="st-add-btn" title="Neues Template">+</button>
      </div>
      <div id="st-list" class="re-engine-list"></div>
      <div class="re-footer">
        <button class="re-save-btn" id="st-save-btn">💾 Speichern</button>
        <span id="st-save-status" style="font-size:11px;color:var(--text-dim)"></span>
      </div>
    </div>
    <div class="re-editor" id="st-editor">
      <div class="empty-state">
        <div class="empty-icon">⊟</div>
        <div>Template links auswählen oder neu erstellen</div>
      </div>
    </div>`;

  try {
    const r = await fetch('/api/step-templates');
    stTemplates = await r.json();
  } catch(_) { stTemplates = []; }
  stSelIdx = null;

  document.getElementById('st-add-btn').addEventListener('click', () => {
    stTemplates.push({ id: 'st_' + Math.random().toString(36).slice(2,9),
                       name: 'Neues Template', procedures: [] });
    stSelIdx = stTemplates.length - 1;
    stRenderList(); stRenderEditor();
  });

  document.getElementById('st-save-btn').addEventListener('click', async () => {
    const btn = document.getElementById('st-save-btn');
    const st  = document.getElementById('st-save-status');
    btn.disabled = true; btn.textContent = 'Speichere…';
    try {
      const r = await fetch('/api/step-templates', {
        method: 'POST', headers: {'Content-Type':'application/json'},
        body: JSON.stringify(stTemplates)
      });
      const d = await r.json();
      st.textContent = d.ok ? '✓ Gespeichert' : '✗ ' + d.error;
      st.style.color = d.ok ? '#22c55e' : '#ef4444';
      setTimeout(() => st.textContent = '', 3000);
    } catch(e) { st.textContent = '✗ ' + e.message; st.style.color = '#ef4444'; }
    finally { btn.disabled = false; btn.textContent = '💾 Speichern'; }
  });

  stRenderList();
}

function stRenderList() {
  const list = document.getElementById('st-list');
  list.innerHTML = '';
  stTemplates.forEach((t, i) => {
    const item = document.createElement('div');
    item.className = 're-engine-item' + (i === stSelIdx ? ' selected' : '');
    const procCount = (t.procedures || []).length;
    item.innerHTML = `
      <span class="re-engine-item-name">${t.name || '—'}</span>
      <span style="font-size:10px;color:var(--text-dim)">${procCount}p</span>
      <button class="re-del-btn" data-i="${i}" title="Löschen">✕</button>`;
    item.addEventListener('click', e => {
      if (e.target.classList.contains('re-del-btn')) {
        stTemplates.splice(parseInt(e.target.dataset.i), 1);
        if (stSelIdx >= stTemplates.length) stSelIdx = stTemplates.length - 1;
        stRenderList(); stRenderEditor(); return;
      }
      stSelIdx = i; stRenderList(); stRenderEditor();
    });
    list.appendChild(item);
  });
}

function stRenderEditor() {
  const ed = document.getElementById('st-editor');
  if (stSelIdx === null || stSelIdx < 0 || stSelIdx >= stTemplates.length) {
    ed.innerHTML = '<div class="empty-state"><div class="empty-icon">⊟</div><div>Template links auswählen oder neu erstellen</div></div>';
    return;
  }
  const tpl = stTemplates[stSelIdx];
  if (!Array.isArray(tpl.procedures)) tpl.procedures = [];

  ed.innerHTML = '';

  // template name
  const nameWrap = document.createElement('div');
  nameWrap.style.cssText = 'padding:16px 20px 12px;border-bottom:1px solid var(--border);';
  const nameLbl = document.createElement('div');
  nameLbl.style.cssText = 'font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--text-dim);margin-bottom:6px;';
  nameLbl.textContent = 'Template Name';
  const nameInp = document.createElement('input'); nameInp.className = 're-input';
  nameInp.style.width = '100%'; nameInp.value = tpl.name || '';
  nameInp.placeholder = 'z.B. CFM56 Standard Run';
  nameInp.addEventListener('input', () => { tpl.name = nameInp.value; stRenderList(); });
  nameWrap.appendChild(nameLbl); nameWrap.appendChild(nameInp);
  ed.appendChild(nameWrap);

  // procedure builder area
  const builderWrap = document.createElement('div');
  builderWrap.style.cssText = 'padding:16px 20px;overflow-y:auto;flex:1;';

  // fake field/nd objects to reuse renderProcedureBuilder
  const fakeField = { key: 'procedures' };
  const fakeNd    = { config: tpl, id: 'st-editor-fake', typeId: 'custom_steps' };
  renderProcedureBuilder(fakeField, fakeNd, builderWrap);

  ed.appendChild(builderWrap);
  ed.style.display = 'flex'; ed.style.flexDirection = 'column'; ed.style.overflow = 'hidden';
}

// ── Node Defaults (Settings) ─────────────────────────────────────
const NODE_DEFAULT_DEFS = [
  {
    typeId: 'pdf_source',
    label:  'WRB PDF',
    icon:   '⬢',
    color:  '#f59e0b',
    fields: [
      { key: 'filePath',      label: 'Standard-Dateipfad',   type: 'text',   placeholder: 'C:\\Pfad\\zur\\datei.pdf' },
      { key: 'engineMapping', label: 'Engine Mapping (ID)',   type: 'text',   placeholder: 'Automatisch per Prefix erkannt' },
    ]
  },
  {
    typeId: 'hb_write',
    label:  'HB Write',
    icon:   '⇣',
    color:  '#f43f5e',
    fields: [
      { key: 'orderName',    label: 'Order Name Format',  type: 'text',     placeholder: '{engine_type}_{esn}' },
      { key: 'engineerName', label: 'Engineer Name',      type: 'text',     placeholder: 'z.B. Max Mustermann' },
      { key: 'operatorName', label: 'Operator Name',      type: 'text',     placeholder: 'z.B. M. Mustermann' },
    ]
  },
  {
    typeId: 'hb_read',
    label:  'HB Read',
    icon:   '⇡',
    color:  '#06b6d4',
    fields: [
      { key: 'testbed', label: 'Standard-Testbed', type: 'text', placeholder: 'z.B. MTU-L-4' },
    ]
  },
  {
    typeId: 'hb_patch',
    label:  'HB Patch',
    icon:   '✎',
    color:  '#fb923c',
    fields: [
      { key: 'attributes', label: 'Standard-Attribute (KEY=VALUE)', type: 'textarea',
        placeholder: 'engineer-name=Max Mustermann' },
    ]
  },
];

async function renderSettingsNodeDefaults() {
  const container = document.getElementById('sset-node-defaults');
  if (container.dataset.loaded) return;
  container.dataset.loaded = '1';

  let saved = {};
  try {
    const r = await fetch('/api/settings');
    saved = (await r.json()).node_defaults || {};
  } catch(_) {}

  container.innerHTML = '';
  const title = document.createElement('div'); title.className = 'ss-title';
  title.textContent = 'Node Defaults';
  const sub = document.createElement('div'); sub.className = 'ss-subtitle';
  sub.textContent = 'Vorausgefüllte Werte für neu angelegte Nodes. Werden sofort wirksam.';
  container.appendChild(title); container.appendChild(sub);

  NODE_DEFAULT_DEFS.forEach(def => {
    const g = document.createElement('div'); g.className = 'ss-group';
    const hdr = document.createElement('div'); hdr.className = 'ss-group-title';
    hdr.innerHTML = `<span style="display:inline-block;width:14px;height:14px;border-radius:3px;background:${def.color};margin-right:7px;vertical-align:middle;font-size:10px;line-height:14px;text-align:center">${def.icon}</span>${def.label}`;
    g.appendChild(hdr);

    def.fields.forEach(f => {
      const fd  = document.createElement('div'); fd.className = 'ss-field';
      const lb  = document.createElement('div'); lb.className = 'ss-label'; lb.textContent = f.label;
      const inp = document.createElement('input'); inp.className = 'ss-input';
      inp.type = f.type || 'text'; inp.placeholder = f.placeholder || '';
      inp.dataset.typeId = def.typeId; inp.dataset.key = f.key;
      inp.value = (saved[def.typeId] || {})[f.key] || '';
      fd.appendChild(lb); fd.appendChild(inp); g.appendChild(fd);
    });
    container.appendChild(g);
  });

  const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:12px;margin-top:8px;';
  const btn = document.createElement('button'); btn.className = 'ss-save-btn'; btn.textContent = '💾 Speichern';
  const st  = document.createElement('span');   st.className  = 'ss-status';
  row.appendChild(btn); row.appendChild(st); container.appendChild(row);

  btn.addEventListener('click', async () => {
    const nd = {};
    container.querySelectorAll('.ss-input[data-type-id]').forEach(inp => {
      const tid = inp.dataset.typeId; const k = inp.dataset.key;
      if (inp.value.trim()) { if (!nd[tid]) nd[tid] = {}; nd[tid][k] = inp.value.trim(); }
    });
    btn.disabled = true; btn.textContent = 'Speichere…';
    try {
      const r = await fetch('/api/settings', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({ node_defaults: nd })
      });
      const d = await r.json();
      if (d.ok) {
        nodeDefaults = nd;   // sofort wirksam für neue Nodes
        st.textContent = '✓ Gespeichert'; st.style.color = '#22c55e';
      } else {
        st.textContent = '✗ ' + d.error; st.style.color = '#ef4444';
      }
      setTimeout(() => st.textContent = '', 3000);
    } catch(e) { st.textContent = '✗ ' + e.message; st.style.color = '#ef4444'; }
    finally { btn.disabled = false; btn.textContent = '💾 Speichern'; }
  });
}

// ══════════════════════════════════════════════════════════════════
//  CIPA Config Settings Tab
// ══════════════════════════════════════════════════════════════════

const CIPA_ROLES = ['text', 'trim_before', 'trim_after', 'engine_config', 'pmux', 'image_before', 'image_after'];
const CIPA_SOURCES = ['hb_read', 'tardis'];
let _cipaConfigData = null;
let _cipaEditMode   = false;

async function renderSettingsCipaConfig() {
  const sec = document.getElementById('sset-cipa-config');
  sec.innerHTML = '';

  let cfg = { mapping: [], pin_config: { trim_levels: {}, engine_configurations: {}, pmux_options: {} } };
  try {
    const r = await fetch('/api/flow/cipa_config');
    cfg = await r.json();
  } catch(_) {}
  _cipaConfigData = JSON.parse(JSON.stringify(cfg));

  // ── Status bar at top ─────────────────────────────────────────
  const statusBar = document.createElement('div');
  statusBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 20px;border-bottom:1px solid rgba(255,255,255,.08);flex-shrink:0;';
  statusBar.innerHTML = `
    <div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:rgba(249,115,22,.85);">◉ CIPA Config — CFM56-7B ID Plug</div>
    <div style="display:flex;gap:8px;align-items:center;">
      <span id="cipa-save-status" style="font-size:11px;"></span>
      <button class="cipa-save-btn" onclick="saveCipaConfig()">💾 Speichern</button>
    </div>`;
  sec.appendChild(statusBar);

  // ── Scroll container ──────────────────────────────────────────
  const scroll = document.createElement('div');
  scroll.style.cssText = 'flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:24px;';
  sec.appendChild(scroll);

  // ── Section A: Field Mapping ──────────────────────────────────
  const mapSec = document.createElement('div');
  mapSec.innerHTML = `<div class="cipa-section-title">Feld-Mapping — Quelle → Platzhalter im Zertifikat</div>
    <div style="font-size:11px;color:rgba(255,255,255,.5);margin-bottom:10px;">
      Sonderrollen (trim_before, trim_after, engine_config, pmux) steuern die Kreisbilder — je Rolle max. 1 Zeile.
    </div>`;

  const mapTable = document.createElement('table');
  mapTable.className = 'cipa-table';
  mapTable.id = 'cipa-map-table';
  mapTable.innerHTML = `<thead><tr>
    <th style="width:90px">Quelle</th>
    <th>Quellfeld / TARDIS-Kanal</th>
    <th style="width:90px">Scan Code</th>
    <th style="width:110px">Rolle</th>
    <th>DOCX-Platzhalter</th>
    <th style="width:30px"></th>
  </tr></thead><tbody id="cipa-map-body"></tbody>`;
  mapSec.appendChild(mapTable);

  const addMapBtn = document.createElement('button');
  addMapBtn.className = 'cipa-add-btn';
  addMapBtn.textContent = '+ Zeile';
  addMapBtn.onclick = () => { _cipaConfigData.mapping.push({source:'hb_read',source_field:'',role:'text',placeholder:''}); _cipaRenderMapRows(); };
  mapSec.appendChild(addMapBtn);
  scroll.appendChild(mapSec);

  // ── ESM Reference Banner + Edit Toggle (vor Pin-Tabellen) ────
  const pinConfigWrapper = document.createElement('div');
  pinConfigWrapper.id = 'cipa-pin-config-wrapper';

  // ESM reference
  pinConfigWrapper.insertAdjacentHTML('beforeend', `
    <div class="cipa-esm-ref">
      <span class="esm-icon">📖</span>
      <div class="esm-text">
        <strong>Quelldokument — Nur lesen, nicht frei editieren</strong>
        CFM56-7B Engine Service Manual (ESM) — Chapter 73-21-00 "Electronic Engine Control".<br>
        Die Pin-Belegungen (Trim Levels, Engine Configurations, PMUX) sind durch das ESM festgelegt.
        Änderungen nur nach Prüfung der aktuellen ESM-Revision vornehmen.
      </div>
    </div>`);

  // Edit toggle bar
  const editBar = document.createElement('div');
  editBar.className = 'cipa-edit-bar';
  editBar.innerHTML = `
    <div style="font-size:11px;color:rgba(255,255,255,.4);">
      🔒 Pin-Tabellen sind gesperrt — nur lesen
    </div>
    <button id="cipa-edit-toggle" class="cipa-edit-btn" onclick="_cipaToggleEditMode()">
      ✎ Bearbeiten
    </button>`;
  pinConfigWrapper.appendChild(editBar);

  // ── Section B: Trim Levels ────────────────────────────────────
  const trimPins = [37, 46, 47, 54];
  const trimSec = document.createElement('div');
  trimSec.innerHTML = `<div class="cipa-section-title">Trim Levels — Pin-Belegung (0–7)</div>`;
  const trimGrid = document.createElement('div');
  trimGrid.id = 'cipa-trim-grid';
  trimSec.appendChild(trimGrid);
  pinConfigWrapper.appendChild(trimSec);

  // ── Section C: Engine Configurations ─────────────────────────
  const cfgPins = [19, 20, 21, 22, 28, 30, 31, 36];
  const cfgSec = document.createElement('div');
  cfgSec.innerHTML = `<div class="cipa-section-title">Engine Configurations — Pin-Belegung</div>`;
  const cfgTable = document.createElement('table');
  cfgTable.className = 'cipa-table';
  cfgTable.id = 'cipa-cfg-table';
  cfgSec.appendChild(cfgTable);
  const addCfgBtn = document.createElement('button');
  addCfgBtn.className = 'cipa-add-btn'; addCfgBtn.textContent = '+ Config';
  addCfgBtn.onclick = () => { _cipaConfigData.pin_config.engine_configurations['Neue Config'] = {}; _cipaRenderCfgTable(cfgPins); };
  cfgSec.appendChild(addCfgBtn);
  pinConfigWrapper.appendChild(cfgSec);

  // ── Section D: PMUX Options ───────────────────────────────────
  const pmuxSec = document.createElement('div');
  pmuxSec.innerHTML = `<div class="cipa-section-title">PMUX Options — Pin 27</div>`;
  const pmuxTable = document.createElement('table');
  pmuxTable.className = 'cipa-table';
  pmuxTable.id = 'cipa-pmux-table';
  pmuxSec.appendChild(pmuxTable);
  const addPmuxBtn = document.createElement('button');
  addPmuxBtn.className = 'cipa-add-btn'; addPmuxBtn.textContent = '+ Option';
  addPmuxBtn.onclick = () => { _cipaConfigData.pin_config.pmux_options['Neue Option'] = {'27':'pushed'}; _cipaRenderPmuxTable(); };
  pmuxSec.appendChild(addPmuxBtn);
  pinConfigWrapper.appendChild(pmuxSec);

  scroll.appendChild(pinConfigWrapper);

  // ── Initial render ────────────────────────────────────────────
  _cipaEditMode = false;  // always start locked
  _cipaRenderMapRows();
  _cipaRenderTrimGrid(trimPins);
  _cipaRenderCfgTable(cfgPins);
  _cipaRenderPmuxTable();
  _cipaApplyEditMode();   // apply locked state to DOM
}

function _cipaToggleEditMode() {
  _cipaEditMode = !_cipaEditMode;
  _cipaApplyEditMode();
}

function _cipaApplyEditMode() {
  const wrapper = document.getElementById('cipa-pin-config-wrapper');
  if (!wrapper) return;

  // Toggle readonly class on the wrapper (CSS hides add/del btns + disables inputs)
  wrapper.classList.toggle('cipa-readonly', !_cipaEditMode);

  // Update the toggle button + status text
  const btn = document.getElementById('cipa-edit-toggle');
  const bar  = btn?.previousElementSibling;
  if (btn) {
    btn.classList.toggle('active', _cipaEditMode);
    btn.textContent = _cipaEditMode ? '🔒 Sperren' : '✎ Bearbeiten';
  }
  if (bar) {
    bar.textContent = _cipaEditMode
      ? '⚠ Bearbeitungsmodus aktiv — Änderungen mit "Speichern" übernehmen'
      : '🔒 Pin-Tabellen sind gesperrt — nur lesen';
    bar.style.color = _cipaEditMode ? 'rgba(249,115,22,.8)' : 'rgba(255,255,255,.4)';
  }
}

function _cipaRenderMapRows() {
  const tbody = document.getElementById('cipa-map-body');
  if (!tbody) return;
  tbody.innerHTML = '';
  (_cipaConfigData.mapping || []).forEach((row, i) => {
    const isSpecial = row.role !== 'text';
    const tr = document.createElement('tr');
    if (isSpecial) tr.className = 'cipa-role-special';
    const isTardis = row.source === 'tardis';
    tr.innerHTML = `
      <td><select onchange="_cipaMapField(${i},'source',this.value);_cipaRenderMapRows()">
        ${CIPA_SOURCES.map(s => `<option value="${s}" ${row.source===s?'selected':''}>${s==='hb_read'?'HB Read':'TARDIS'}</option>`).join('')}
      </select></td>
      <td><input type="text" value="${_esc(row.source_field)}" placeholder="${isTardis?'Kanalname':'HB-Feldname'}"
          oninput="_cipaMapField(${i},'source_field',this.value)"></td>
      <td><input type="text" value="${_esc(row.scan_code||'')}" placeholder="${isTardis?'z.B. T4O':''}"
          ${isTardis?'':'disabled style="opacity:.25"'}
          oninput="_cipaMapField(${i},'scan_code',this.value)"></td>
      <td><select onchange="_cipaMapField(${i},'role',this.value)">
        ${CIPA_ROLES.map(r => `<option value="${r}" ${row.role===r?'selected':''}>${r}</option>`).join('')}
      </select></td>
      <td><input type="text" value="${_esc(row.placeholder)}" placeholder="{{LB_...}}"
          oninput="_cipaMapField(${i},'placeholder',this.value)"></td>
      <td><button class="cipa-del-btn" onclick="_cipaMapDel(${i})">✕</button></td>`;
    tbody.appendChild(tr);
  });
}

function _cipaMapField(i, key, val) {
  if (!_cipaConfigData.mapping[i]) return;
  _cipaConfigData.mapping[i][key] = val;
  if (key === 'role') {
    const tr = document.getElementById('cipa-map-body')?.rows[i];
    if (tr) tr.className = val !== 'text' ? 'cipa-role-special' : '';
  }
}
function _cipaMapDel(i) { _cipaConfigData.mapping.splice(i, 1); _cipaRenderMapRows(); }

function _cipaRenderTrimGrid(pins) {
  const grid = document.getElementById('cipa-trim-grid');
  if (!grid) return;
  grid.className = 'cipa-pin-grid';
  grid.style.gridTemplateColumns = `80px repeat(${pins.length}, 1fr)`;
  grid.innerHTML = `<div class="pg-header">Level</div>` + pins.map(p => `<div class="pg-header">Pin ${p}</div>`).join('');
  const tls = _cipaConfigData.pin_config.trim_levels || {};
  for (let lvl = 0; lvl <= 7; lvl++) {
    const key = String(lvl);
    if (!tls[key]) tls[key] = {};
    grid.insertAdjacentHTML('beforeend', `<div class="pg-label">${lvl}</div>`);
    pins.forEach(pin => {
      const sel = document.createElement('select');
      sel.innerHTML = '<option value="pulled">pull</option><option value="pushed">push</option>';
      sel.value = tls[key][String(pin)] || 'pulled';
      sel.onchange = () => { tls[key][String(pin)] = sel.value; };
      grid.appendChild(sel);
    });
  }
}

function _cipaRenderCfgTable(pins) {
  const table = document.getElementById('cipa-cfg-table');
  if (!table) return;
  const ecs = _cipaConfigData.pin_config.engine_configurations || {};
  table.innerHTML = `<thead><tr><th>Config Name</th>${pins.map(p=>`<th>Pin ${p}</th>`).join('')}<th></th></tr></thead>`;
  const tbody = document.createElement('tbody');
  Object.entries(ecs).forEach(([name, pinMap]) => {
    const tr = document.createElement('tr');
    let cells = `<td><input type="text" value="${_esc(name)}" oninput="_cipaCfgRename('${_esc(name)}',this.value)" style="width:100%"></td>`;
    pins.forEach(pin => {
      cells += `<td><select onchange="_cipaCfgPin('${_esc(name)}','${pin}',this.value)">
        <option value="pulled" ${(pinMap[String(pin)]||'pulled')==='pulled'?'selected':''}>pull</option>
        <option value="pushed" ${(pinMap[String(pin)]||'')==='pushed'?'selected':''}>push</option>
      </select></td>`;
    });
    cells += `<td><button class="cipa-del-btn" onclick="_cipaCfgDel('${_esc(name)}')">✕</button></td>`;
    tr.innerHTML = cells;
    tbody.appendChild(tr);
  });
  table.appendChild(tbody);
}

function _cipaCfgRename(oldName, newName) {
  const ecs = _cipaConfigData.pin_config.engine_configurations;
  if (oldName === newName || !ecs[oldName]) return;
  ecs[newName] = ecs[oldName]; delete ecs[oldName];
}
function _cipaCfgPin(name, pin, val) {
  const ecs = _cipaConfigData.pin_config.engine_configurations;
  if (!ecs[name]) return;
  ecs[name][String(pin)] = val;
}
function _cipaCfgDel(name) {
  delete _cipaConfigData.pin_config.engine_configurations[name];
  _cipaRenderCfgTable([19,20,21,22,28,30,31,36]);
}

function _cipaRenderPmuxTable() {
  const table = document.getElementById('cipa-pmux-table');
  if (!table) return;
  const pmux = _cipaConfigData.pin_config.pmux_options || {};
  table.innerHTML = `<thead><tr><th>Option Name</th><th>Pin 27</th><th></th></tr></thead>`;
  const tbody = document.createElement('tbody');
  Object.entries(pmux).forEach(([name, pinMap]) => {
    const tr = document.createElement('tr');
    tr.innerHTML = `
      <td><input type="text" value="${_esc(name)}" oninput="_cipaPmuxRename('${_esc(name)}',this.value)" style="width:100%"></td>
      <td><select onchange="_cipaPmuxPin('${_esc(name)}',this.value)">
        <option value="pushed" ${(pinMap['27']||'pushed')==='pushed'?'selected':''}>push</option>
        <option value="pulled" ${(pinMap['27']||'')==='pulled'?'selected':''}>pull</option>
      </select></td>
      <td><button class="cipa-del-btn" onclick="_cipaPmuxDel('${_esc(name)}')">✕</button></td>`;
    tbody.appendChild(tr);
  });
  table.appendChild(tbody);
}

function _cipaPmuxRename(oldName, newName) {
  const po = _cipaConfigData.pin_config.pmux_options;
  if (oldName === newName || !po[oldName]) return;
  po[newName] = po[oldName]; delete po[oldName];
}
function _cipaPmuxPin(name, val) {
  const po = _cipaConfigData.pin_config.pmux_options;
  if (!po[name]) return;
  po[name]['27'] = val;
}
function _cipaPmuxDel(name) {
  delete _cipaConfigData.pin_config.pmux_options[name];
  _cipaRenderPmuxTable();
}

function _esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }

async function saveCipaConfig() {
  const st = document.getElementById('cipa-save-status');
  st.textContent = 'Speichere…'; st.style.color = 'rgba(255,255,255,.5)';
  try {
    const r = await fetch('/api/flow/cipa_config', {
      method: 'PUT', headers: {'Content-Type':'application/json'},
      body: JSON.stringify(_cipaConfigData)
    });
    const d = await r.json();
    if (d.ok) { st.textContent = '✓ Gespeichert'; st.style.color = '#22c55e'; }
    else       { st.textContent = '✗ ' + d.error;  st.style.color = '#ef4444'; }
  } catch(e) { st.textContent = '✗ ' + e.message; st.style.color = '#ef4444'; }
  setTimeout(() => st.textContent = '', 3000);
}

// ── Recipe Editor (Settings) ──────────────────────────────────────
let recipes = [];
let reSelectedIdx = null;
let reCodesCache = {};
let reChannelsCache = {};
let reDragSrc = null;

let _manual = {};
async function _loadManual() {
  if (Object.keys(_manual).length) return;
  _manual = await fetch('/api/manual').then(r => r.json()).catch(() => {});
}

function _mdToHtml(text) {
  return text
    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
    .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
    .replace(/`([^`]+)`/g, '<code style="font-family:monospace;background:var(--hover);padding:1px 4px;border-radius:3px">$1</code>')
    .replace(/^- (.+)$/gm, '<span style="display:block;padding-left:12px">◂ $1</span>')
    .replace(/\n/g, '<br>');
}

function renderHelp(key) {
  const entry = _manual[key];
  if (!entry) return '';
  const id = 'help-' + key.replace(/\./g,'-');
  return `<details class="manual-help" id="${id}">
    <summary class="manual-help-summary">◈ ${entry.title || 'Hilfe'}</summary>
    <div class="manual-help-body">${_mdToHtml(entry.text || '')}</div>
  </details>`;
}

async function renderSettingsRecipes() {
  const sec = document.getElementById('sset-recipes');
  if (sec.dataset.loaded) return;
  sec.dataset.loaded = '1';
  await _loadManual();
  recipes = await fetch('/api/recipes').then(r => r.json()).catch(() => []);
  sec.innerHTML = `
    <div class="re-sidebar">
      <div style="padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
        <span style="font-size:11px;letter-spacing:0.15em;text-transform:uppercase;color:var(--text-dim)">Engine Types</span>
        <button class="re-icon-btn" onclick="addEngineType()" title="Neu">+</button>
      </div>
      <div id="re-engine-list" class="re-engine-list"></div>
      <div class="re-footer">
        <button class="re-save-btn" onclick="saveRecipes()">💾 Speichern</button>
        <span id="re-save-status" style="font-size:11px;color:var(--text-dim)"></span>
      </div>
    </div>
    <div class="re-editor" id="re-editor">
      <div class="empty-state">
        <div class="empty-icon">◈</div>
        <div>Engine Type links auswählen oder neu erstellen</div>
      </div>
    </div>`;
  renderEngineList();
}

function renderEngineList() {
  const el = document.getElementById('re-engine-list');
  if (!el) return;
  if (!recipes.length) {
    el.innerHTML = '<div style="padding:14px;color:var(--text-dim);font-size:12px">Noch keine Engine Types</div>';
    return;
  }
  el.innerHTML = recipes.map((r, i) => `
    <div class="re-engine-item ${i===reSelectedIdx?'selected':''}" onclick="selectEngineType(${i})">
      <span class="re-engine-item-name">◈ ${r.engine_type||'Unbenannt'}</span>
      <button class="re-del-btn" onclick="event.stopPropagation();deleteEngineType(${i})" title="Löschen">🗑</button>
    </div>`).join('');
}

function addEngineType() {
  recipes.push({ engine_type:'Neu', project_filter:'', required_scans:[] });
  reSelectedIdx = recipes.length - 1;
  renderEngineList(); renderEditor();
}

function deleteEngineType(idx) {
  if (!confirm(`"${recipes[idx].engine_type}" wirklich löschen?`)) return;
  recipes.splice(idx, 1); reSelectedIdx = null; renderEngineList();
  document.getElementById('re-editor').innerHTML = '<div class="empty-state"><div class="empty-icon">◈</div><div>Engine Type auswählen</div></div>';
}

function selectEngineType(idx) { reSelectedIdx = idx; renderEngineList(); renderEditor(); }

async function renderEditor() {
  const r = recipes[reSelectedIdx];
  const editor = document.getElementById('re-editor');
  const projects = await fetch('/api/tardis/projects').then(r => r.json()).catch(() => []);
  const projOpts = projects.map(p => `<option value="${p.name}" ${p.name===r.project_filter?'selected':''}>${p.name}</option>`).join('');
  editor.innerHTML = `
    <div class="re-engine-header">
      <div style="flex:1">
        <div class="re-field" style="margin-bottom:10px">
          <div class="re-label">Engine Type Name</div>
          <input class="re-input" style="width:280px" value="${r.engine_type||''}"
            oninput="recipes[${reSelectedIdx}].engine_type=this.value;renderEngineList()">
        </div>
        <div class="re-field">
          <div class="re-label">Project (TARDIS)</div>
          <select class="re-select" style="width:280px" onchange="onReProjectChange(${reSelectedIdx},this.value)">
            <option value="">-- wählen --</option>${projOpts}
          </select>
        </div>
      </div>
    </div>
    <div class="re-subtabs">
      <button class="re-subtab active" id="re-subtab-scans-${reSelectedIdx}" onclick="switchReSubtab(${reSelectedIdx},'scans')">▤ Required Scans</button>
      <button class="re-subtab" id="re-subtab-ratings-${reSelectedIdx}" onclick="switchReSubtab(${reSelectedIdx},'ratings')">🏷️ Ratings</button>
      <button class="re-subtab" id="re-subtab-certtypes-${reSelectedIdx}" onclick="switchReSubtab(${reSelectedIdx},'certtypes')">◻ Zertifikatstypen</button>
      <button class="re-subtab" id="re-subtab-meta-${reSelectedIdx}" onclick="switchReSubtab(${reSelectedIdx},'meta')">🗂 Metadaten</button>
    </div>
    <div id="re-subtab-content-scans-${reSelectedIdx}">
      <div id="re-codes-container"><div style="color:var(--text-dim);font-size:12px;padding:8px 0">Lade Optionen...</div></div>
      <button class="re-add-btn" onclick="addCode(${reSelectedIdx})">+ Code hinzufügen</button>
      <div id="re-scans-help"></div>
    </div>
    <div id="re-subtab-content-ratings-${reSelectedIdx}" style="display:none">
      <div id="re-ratings-container"></div>
      <button class="re-add-btn" onclick="addRating(${reSelectedIdx})">+ Rating hinzufügen</button>
      <div id="re-ratings-help"></div>
    </div>
    <div id="re-subtab-content-certtypes-${reSelectedIdx}" style="display:none">
      <div id="re-certtypes-container"></div>
      <button class="re-add-btn" onclick="addCertType(${reSelectedIdx})">+ Zertifikatstyp hinzufügen</button>
      <div id="re-certtypes-help"></div>
    </div>
    <div id="re-subtab-content-meta-${reSelectedIdx}" style="display:none">
      <div id="re-meta-container"></div>
    </div>`;
  if (r.project_filter) await onReProjectChange(reSelectedIdx, r.project_filter);
  else await renderCodes(reSelectedIdx);
  renderRatings(reSelectedIdx); renderCertTypes(reSelectedIdx); renderMeta(reSelectedIdx);
}

async function onReProjectChange(recipeIdx, project) {
  recipes[recipeIdx].project_filter = project;
  if (project && !reCodesCache[project]) {
    const d = await fetch(`/api/available_codes?project=${encodeURIComponent(project)}`).then(r => r.json()).catch(() => ({}));
    reCodesCache[project] = d.codes || [];
  }
  if (project && !reChannelsCache[project]) {
    const d = await fetch(`/api/available_channels?project=${encodeURIComponent(project)}`).then(r => r.json()).catch(() => ({}));
    reChannelsCache[project] = d.channels || [];
  }
  renderCodes(recipeIdx);
}

async function renderCodes(recipeIdx) {
  const r = recipes[recipeIdx];
  const codes    = reCodesCache[r.project_filter] || [];
  const channels = reChannelsCache[r.project_filter] || [];
  const container = document.getElementById('re-codes-container');
  if (!container) return;
  const dlCodes = `dl-codes-${recipeIdx}`, dlCh = `dl-ch-${recipeIdx}`;
  let html = `<datalist id="${dlCodes}">${codes.map(c=>`<option value="${c}">`).join('')}</datalist>
              <datalist id="${dlCh}">${channels.map(c=>`<option value="${c}">`).join('')}</datalist>`;
  (r.required_scans || []).forEach((scan, si) => {
    const col = scan._collapsed || false;
    html += `<div class="re-code-card" draggable="true"
      ondragstart="reDragStart(event,${recipeIdx},'scans',${si})"
      ondragover="reDragOver(event)" ondrop="reDrop(event,${recipeIdx},'scans',${si})"
      ondragleave="reDragLeave(event)">
      <div class="re-code-header" style="cursor:pointer" onclick="toggleReCard(${recipeIdx},'scans',${si})">
        <span class="re-drag-handle" onclick="event.stopPropagation()">⠿</span>
        <span class="re-code-badge">CODE</span>
        <input class="re-input" style="width:180px" placeholder="z.B. DryMotor" value="${scan.code||''}"
          list="${dlCodes}" onclick="event.stopPropagation()"
          oninput="recipes[${recipeIdx}].required_scans[${si}].code=this.value">
        <input class="re-input" style="flex:1" placeholder="Label (optional)" value="${scan.label||''}"
          onclick="event.stopPropagation()"
          oninput="recipes[${recipeIdx}].required_scans[${si}].label=this.value">
        <span style="color:var(--text-dim);font-size:12px;margin-left:4px">${(scan.parameters||[]).length} Param.</span>
        <button class="re-icon-btn" onclick="event.stopPropagation();removeCode(${recipeIdx},${si})">🗑</button>
        <span style="color:var(--text-dim);font-size:14px;margin-left:4px">${col?'▶':'▼'}</span>
      </div>
      <div class="re-code-body" style="display:${col?'none':'block'}">
        <div class="re-param-header-row" style="grid-template-columns:1fr 1fr 60px 1fr 1fr 1fr 28px">
          <span>Kanal</span><span>Label</span><span>Einheit</span>
          <span style="color:var(--status-warn)">▼ Limit Min</span>
          <span style="color:var(--status-err)">▲ Limit Max</span>
          <span style="color:var(--accent)">{{Platzhalter}}</span><span></span>
        </div>
        ${(scan.parameters||[]).map((p,pi) => `
        <div class="re-param-row" style="grid-template-columns:1fr 1fr 60px 1fr 1fr 1fr 28px">
          <input class="re-input" placeholder="Kanalname" value="${p.channel||''}" list="${dlCh}"
            oninput="recipes[${recipeIdx}].required_scans[${si}].parameters[${pi}].channel=this.value">
          <input class="re-input" placeholder="Label" value="${p.label||''}"
            oninput="recipes[${recipeIdx}].required_scans[${si}].parameters[${pi}].label=this.value">
          <input class="re-input" placeholder="°C" value="${p.unit||''}"
            oninput="recipes[${recipeIdx}].required_scans[${si}].parameters[${pi}].unit=this.value">
          <input class="re-input" placeholder="Min (opt.)" value="${p.limit_min||''}" list="${dlCh}"
            style="border-color:${p.limit_min?'var(--status-warn)':''};"
            oninput="recipes[${recipeIdx}].required_scans[${si}].parameters[${pi}].limit_min=this.value;this.style.borderColor=this.value?'var(--status-warn)':''">
          <input class="re-input" placeholder="Max (opt.)" value="${p.limit_max||''}" list="${dlCh}"
            style="border-color:${p.limit_max?'var(--status-err)':''};"
            oninput="recipes[${recipeIdx}].required_scans[${si}].parameters[${pi}].limit_max=this.value;this.style.borderColor=this.value?'var(--status-err)':''">
          <input class="re-input" placeholder="{{N1_Dry}}" value="${p.placeholder||''}"
            style="border-color:${p.placeholder?'var(--accent)':''};"
            oninput="recipes[${recipeIdx}].required_scans[${si}].parameters[${pi}].placeholder=this.value;this.style.borderColor=this.value?'var(--accent)':''">
          <button class="re-icon-btn" onclick="removeParam(${recipeIdx},${si},${pi})">🗑</button>
        </div>`).join('')}
        <button class="re-add-btn" onclick="addParam(${recipeIdx},${si})">+ Parameter</button>
      </div>
    </div>`;
  });
  container.innerHTML = html;
  const helpScans = document.getElementById('re-scans-help');
  if (helpScans) helpScans.innerHTML = renderHelp('recipes.scans');
}

function addCode(ri)   { if(!recipes[ri].required_scans) recipes[ri].required_scans=[]; recipes[ri].required_scans.push({code:'',label:'',parameters:[]}); renderCodes(ri); }
function removeCode(ri,si) { recipes[ri].required_scans.splice(si,1); renderCodes(ri); }
function addParam(ri,si) { if(!recipes[ri].required_scans[si].parameters) recipes[ri].required_scans[si].parameters=[]; recipes[ri].required_scans[si].parameters.push({channel:'',label:'',unit:''}); renderCodes(ri); }
function removeParam(ri,si,pi) { recipes[ri].required_scans[si].parameters.splice(pi,1); renderCodes(ri); }
function toggleReCard(ri,key,idx) {
  const list = key==='scans' ? recipes[ri].required_scans : recipes[ri].ratings;
  list[idx]._collapsed = !list[idx]._collapsed;
  if(key==='scans') renderCodes(ri); else renderRatings(ri);
}

function renderRatings(ri) {
  const container = document.getElementById('re-ratings-container'); if(!container) return;
  const r=recipes[ri], ratings=r.ratings||[], allCodes=(r.required_scans||[]).map(s=>s.code).filter(Boolean);
  if(!allCodes.length) { container.innerHTML='<div style="color:var(--text-dim);font-size:12px;padding:8px 0">Zuerst Required Scans definieren.</div>'; return; }
  if(!ratings.length)  { container.innerHTML='<div style="color:var(--text-dim);font-size:12px;padding:8px 0">Noch keine Ratings.</div>'; return; }
  container.innerHTML = ratings.map((rating, rix) => {
    const items = allCodes.map(code => {
      const chk = (rating.scans||[]).includes(code);
      return `<div class="rating-scan-item ${chk?'checked':''}" onclick="toggleRatingScan(${ri},${rix},'${code}')">
        <div class="rs-check">${chk?'✓':''}</div>
        <div style="flex:1;font-weight:600;font-size:12px">${code}</div></div>`;
    }).join('');
    const col = rating._collapsed||false;
    return `<div class="rating-card" draggable="true"
      ondragstart="reDragStart(event,${ri},'ratings',${rix})"
      ondragover="reDragOver(event)" ondrop="reDrop(event,${ri},'ratings',${rix})"
      ondragleave="reDragLeave(event)">
      <div class="rating-card-header" style="cursor:pointer" onclick="toggleReCard(${ri},'ratings',${rix})">
        <span class="re-drag-handle" onclick="event.stopPropagation()">⠿</span>
        <input class="rating-name-input" value="${rating.name||''}" onclick="event.stopPropagation()"
          oninput="recipes[${ri}].ratings[${rix}].name=this.value">
        <span style="margin-left:auto;font-size:11px;color:var(--text-dim)">${(rating.scans||[]).length}/${allCodes.length} Scans</span>
        <button class="re-icon-btn" onclick="event.stopPropagation();removeRating(${ri},${rix})">🗑</button>
        <span style="color:var(--text-dim);font-size:14px;margin-left:4px">${col?'▶':'▼'}</span>
      </div>
      <div class="rating-card-body" style="display:${col?'none':'block'}">
        <div class="rating-scan-grid">${items}</div>
      </div></div>`;
  }).join('');
  const helpRatings = document.getElementById('re-ratings-help');
  if (helpRatings) helpRatings.innerHTML = renderHelp('recipes.ratings');
}
function addRating(ri)        { if(!recipes[ri].ratings) recipes[ri].ratings=[]; recipes[ri].ratings.push({name:'Neues Rating',scans:[]}); renderRatings(ri); }
function removeRating(ri,rix) { recipes[ri].ratings.splice(rix,1); renderRatings(ri); }
function toggleRatingScan(ri,rix,code) {
  const s=recipes[ri].ratings[rix].scans, idx=s.indexOf(code);
  idx===-1 ? s.push(code) : s.splice(idx,1); renderRatings(ri);
}

function renderCertTypes(ri) {
  const c=document.getElementById('re-certtypes-container'); if(!c) return;
  const types=recipes[ri].cert_types||[];
  if(!types.length){c.innerHTML='<div style="color:var(--text-dim);font-size:12px;padding:8px 0">Noch keine Zertifikatstypen.</div>';return;}
  c.innerHTML='<div style="display:grid;grid-template-columns:1fr 2fr 32px;gap:12px;padding:0 14px 6px 14px"><span style="font-size:10px;color:var(--text-dim);letter-spacing:1px;text-transform:uppercase">Name</span><span style="font-size:10px;color:var(--text-dim);letter-spacing:1px;text-transform:uppercase">Dateipfad (.docx/.xlsx)</span><span></span></div>'
    + types.map((ct,i)=>`<div class="certtype-card">
      <input class="re-input" placeholder="z.B. Results, ETC..." value="${ct.name||''}"
        oninput="recipes[${ri}].cert_types[${i}].name=this.value">
      <input class="re-input" style="width:100%" placeholder="z.B. C:/Vorlagen/cfm56.docx" value="${ct.template||''}"
        style="border-color:${ct.template?'var(--accent)':''};"
        oninput="recipes[${ri}].cert_types[${i}].template=this.value;this.style.borderColor=this.value?'var(--accent)':''">
      <button class="re-icon-btn" onclick="removeCertType(${ri},${i})">🗑</button>
    </div>`).join('');
  const helpCert = document.getElementById('re-certtypes-help');
  if (helpCert) helpCert.innerHTML = renderHelp('recipes.certtypes');
}
function addCertType(ri)      { if(!recipes[ri].cert_types) recipes[ri].cert_types=[]; recipes[ri].cert_types.push({name:'',template:''}); renderCertTypes(ri); }
function removeCertType(ri,i) { recipes[ri].cert_types.splice(i,1); renderCertTypes(ri); }

function renderMeta(ri) {
  const c=document.getElementById('re-meta-container'); if(!c) return;
  const schema=recipes[ri].meta_schema||{}, params=schema.params||[], apiUrl=schema.api_url||'';
  const rows = params.map((p,pi)=>`
    <div style="display:grid;grid-template-columns:1fr 1fr 1fr 28px;gap:10px;align-items:center;margin-bottom:8px">
      <input class="re-input" placeholder="z.B. Prüfer Name" value="${p.name||''}"
        oninput="if(!recipes[${ri}].meta_schema)recipes[${ri}].meta_schema={params:[]};recipes[${ri}].meta_schema.params[${pi}].name=this.value">
      <input class="re-input" style="font-family:monospace" placeholder="z.B. prufer_name" value="${p.ref||''}"
        oninput="if(!recipes[${ri}].meta_schema)recipes[${ri}].meta_schema={params:[]};recipes[${ri}].meta_schema.params[${pi}].ref=this.value.replace(/\\s+/g,'_').toLowerCase();this.value=recipes[${ri}].meta_schema.params[${pi}].ref">
      <input class="re-input" style="border-color:${p.placeholder?'var(--accent)':''};" placeholder="{{Pruefer}}" value="${p.placeholder||''}"
        oninput="if(!recipes[${ri}].meta_schema)recipes[${ri}].meta_schema={params:[]};recipes[${ri}].meta_schema.params[${pi}].placeholder=this.value;this.style.borderColor=this.value?'var(--accent)':''">
      <button class="re-icon-btn" onclick="removeMetaParam(${ri},${pi})">🗑</button>
    </div>`).join('');
  c.innerHTML=`<div style="max-width:700px;display:flex;flex-direction:column;gap:16px">
    <div style="background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px 16px">
      <div style="font-size:10px;color:var(--text-dim);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:10px">Externe API</div>
      <div class="re-field">
        <div class="re-label">GET URL</div>
        <input class="re-input" style="width:100%;font-family:monospace" placeholder="https://..." value="${apiUrl}"
          style="border-color:${apiUrl?'var(--accent)':''};"
          oninput="if(!recipes[${ri}].meta_schema)recipes[${ri}].meta_schema={params:[]};recipes[${ri}].meta_schema.api_url=this.value;this.style.borderColor=this.value?'var(--accent)':''">
      </div>
    </div>
    <div style="background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px 16px">
      <div style="font-size:10px;color:var(--text-dim);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:12px">Metadaten-Parameter (${params.length})</div>
      ${params.length?`<div style="display:grid;grid-template-columns:1fr 1fr 1fr 28px;gap:10px;padding:0 0 6px 0"><span style="font-size:9px;color:var(--text-dim);text-transform:uppercase">Name</span><span style="font-size:9px;color:var(--text-dim);text-transform:uppercase">API-Key</span><span style="font-size:9px;color:var(--accent);text-transform:uppercase">Platzhalter</span><span></span></div>`:''}
      ${rows}
      <button class="re-add-btn" onclick="addMetaParam(${ri})">+ Parameter hinzufügen</button>
    </div>
  </div>
  ${renderHelp('recipes.meta')}`;
}
function addMetaParam(ri) { if(!recipes[ri].meta_schema) recipes[ri].meta_schema={api_url:'',params:[]}; if(!recipes[ri].meta_schema.params) recipes[ri].meta_schema.params=[]; recipes[ri].meta_schema.params.push({name:'',ref:'',placeholder:''}); renderMeta(ri); }
function removeMetaParam(ri,i) { recipes[ri].meta_schema.params.splice(i,1); renderMeta(ri); }

function switchReSubtab(ri, tab) {
  ['scans','ratings','certtypes','meta'].forEach(t => {
    const btn=document.getElementById(`re-subtab-${t}-${ri}`);
    const ct=document.getElementById(`re-subtab-content-${t}-${ri}`);
    if(btn) btn.classList.toggle('active', t===tab);
    if(ct)  ct.style.display = t===tab ? '' : 'none';
  });
  if(tab==='scans')     renderCodes(ri);
  if(tab==='ratings')   renderRatings(ri);
  if(tab==='certtypes') renderCertTypes(ri);
  if(tab==='meta')      renderMeta(ri);
}

async function saveRecipes() {
  const status = document.getElementById('re-save-status');
  status.textContent = 'Speichere...';
  try {
    const r = await fetch('/api/recipes', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(recipes)});
    const d = await r.json();
    status.textContent = d.ok ? '✓ Gespeichert' : '✗ ' + d.error;
    setTimeout(() => status.textContent = '', 3000);
  } catch(e) { status.textContent = '✗ ' + e.message; }
}

function reDragStart(e,ri,key,idx) { reDragSrc={ri,key,idx}; e.dataTransfer.effectAllowed='move'; e.currentTarget.classList.add('dragging'); }
function reDragOver(e)             { e.preventDefault(); e.dataTransfer.dropEffect='move'; e.currentTarget.classList.add('drag-over'); }
function reDragLeave(e)            { e.currentTarget.classList.remove('drag-over'); }
function reDrop(e,ri,key,targetIdx) {
  e.preventDefault(); e.currentTarget.classList.remove('drag-over');
  if(!reDragSrc || reDragSrc.ri!==ri || reDragSrc.key!==key) return;
  const src=reDragSrc.idx; if(src===targetIdx) return;
  const list = key==='scans' ? recipes[ri].required_scans : recipes[ri].ratings;
  const [m]=list.splice(src,1); list.splice(targetIdx,0,m);
  reDragSrc=null;
  if(key==='scans') renderCodes(ri); else renderRatings(ri);
}
</script>
</body>
</html>
"""

FLOW_HTML = (FLOW_HTML
    .replace('__SIDEBAR_CSS__', SIDEBAR_CSS)
    .replace('__SIDEBAR_JS__', SIDEBAR_JS)
    .replace('__SIDEBAR_HTML__', sidebar_html('flow'))
)


@app.route('/flow')
def flow():
    return FLOW_HTML

if __name__ == '__main__':
    print("Animation läuft auf http://localhost:5012")
    app.run(debug=True, port=5012, threaded=True, use_reloader=False)
